Compare commits
255 Commits
9d20da6a16
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
cd507ac25f
|
|||
|
82659cedef
|
|||
|
7eafcde1d7
|
|||
|
a2ceaa0966
|
|||
|
f1931c76e6
|
|||
|
65ad672748
|
|||
|
b9c4ec3911
|
|||
|
0c2e920206
|
|||
|
9682295dae
|
|||
|
f798bb5d7d
|
|||
|
68958e304b
|
|||
|
d2cdeaed1d
|
|||
|
9d8404b774
|
|||
|
84e69187ff
|
|||
|
0e71f597c9
|
|||
|
76d600f01d
|
|||
|
54ed6fef3a
|
|||
|
7c0cb623e3
|
|||
|
9c4f271259
|
|||
|
d6b44da6c2
|
|||
|
d0daaf4494
|
|||
|
7db111d18b
|
|||
|
dd54f5fe33
|
|||
|
4aa4e58c58
|
|||
|
ce9bca0a75
|
|||
|
099b5c135e
|
|||
|
5d53a0d179
|
|||
|
f31752797e
|
|||
|
0b845b75c4
|
|||
|
af57e2f10c
|
|||
|
40219f2b54
|
|||
|
4a45b62521
|
|||
|
fc55aaf87a
|
|||
|
db68ef2c3d
|
|||
|
a808137e5b
|
|||
|
a93a89f0df
|
|||
|
7aa3a9382e
|
|||
|
46704df7d9
|
|||
|
98bf430604
|
|||
|
21ace9299f
|
|||
|
122b706350
|
|||
|
c655caab9e
|
|||
|
b2d16e305d
|
|||
|
a8398cad51
|
|||
|
f27d8eaf7e
|
|||
|
36e17c6677
|
|||
|
d7a90745f6
|
|||
|
d90b4643cb
|
|||
|
d82f25471d
|
|||
|
791911b416
|
|||
|
ba2c9132f6
|
|||
|
d4e3d7cded
|
|||
|
0898c56a51
|
|||
|
96c37f9081
|
|||
|
94a4be8b97
|
|||
|
fa1140895a
|
|||
|
fc6c5d46e1
|
|||
|
dc0aa0dba7
|
|||
|
dbf0150a5e
|
|||
|
1539486456
|
|||
|
c18dad4a77
|
|||
|
2b45cab4e8
|
|||
|
37c1ffc2a1
|
|||
|
09a19b5352
|
|||
|
6c96563a0e
|
|||
|
77677eef6d
|
|||
|
f99ae75503
|
|||
|
552fb67c6c
|
|||
|
e9c03b9046
|
|||
|
f0b0fb8909
|
|||
|
9ae4e376b8
|
|||
|
d1bc1c644b
|
|||
|
7840399d01
|
|||
|
508b313871
|
|||
|
db677abaa5
|
|||
|
65abea2093
|
|||
|
1533f82a6b
|
|||
|
35483c27aa
|
|||
|
005d2f3b6c
|
|||
|
265e249eaf
|
|||
|
b812e01473
|
|||
|
88f80c38cc
|
|||
|
c70f13d069
|
|||
|
73af2dc3b9
|
|||
|
062cab44bc
|
|||
|
3baccb87b1
|
|||
|
3742749cf6
|
|||
|
eb76338c4a
|
|||
|
9951ed3fae
|
|||
|
a7876ca410
|
|||
|
7c037d1593
|
|||
|
24fe0aba30
|
|||
|
a185208fc1
|
|||
|
1d5d5a8c64
|
|||
|
414298b4b4
|
|||
|
c3a3ead852
|
|||
|
54907db896
|
|||
|
db2d09cb03
|
|||
|
5c03ba3d3a
|
|||
|
3a9f8a111b
|
|||
|
bbe57d6e94
|
|||
|
0bed6b58ae
|
|||
|
8164e63b09
|
|||
|
8b5b38e38b
|
|||
|
d0dfd3a4c3
|
|||
|
fca214dfcf
|
|||
|
04fd3f5d20
|
|||
|
1a3c015612
|
|||
|
fc9ae63471
|
|||
|
4d88b5c24c
|
|||
|
fefdbdb493
|
|||
|
d0c82cf9a9
|
|||
|
90fe38497d
|
|||
|
97e2c041c9
|
|||
|
bbbe152ff8
|
|||
|
a3ad36e9a9
|
|||
|
48fcadf61e
|
|||
|
62e1724f6c
|
|||
|
19383a538d
|
|||
|
2d3eef6531
|
|||
|
e874d41fbc
|
|||
|
844499383c
|
|||
|
4e8b7d2172
|
|||
|
075a9bd498
|
|||
|
962b833a80
|
|||
|
71b04ca4bd
|
|||
|
831eb32b8a
|
|||
|
10934c557d
|
|||
|
4b70ae1b43
|
|||
|
729b7300e6
|
|||
|
f8101e57c1
|
|||
|
95decd9a56
|
|||
|
b86e049263
|
|||
|
5233f2ef4c
|
|||
|
81183f2c02
|
|||
|
86cd55c25b
|
|||
|
a8013f7718
|
|||
|
d2bf93abe6
|
|||
|
ef95da4d47
|
|||
|
661d1ee1b1
|
|||
|
98188c1c69
|
|||
|
64cfbbc057
|
|||
|
6cfc862d63
|
|||
|
70646ba381
|
|||
|
f04f0fb51b
|
|||
|
317182ae12
|
|||
|
751be27b52
|
|||
|
6dd9f5bf65
|
|||
|
1f80ed7ca5
|
|||
|
89817340c9
|
|||
|
fc80823713
|
|||
|
184472726e
|
|||
|
68cf5f7d57
|
|||
|
4ef7b0ba1e
|
|||
|
aaeb3a524b
|
|||
|
f1f62fa2c8
|
|||
|
8c917f6ae2
|
|||
|
4f88d14b45
|
|||
|
9238385244
|
|||
|
cf89070639
|
|||
|
4a8f87d64a
|
|||
|
2b1f52a99d
|
|||
|
d0b702e1e8
|
|||
|
14b96bf37e
|
|||
|
cf4bf3caa3
|
|||
|
382080ceaa
|
|||
|
304a862931
|
|||
|
348b782350
|
|||
|
aec4724e2f
|
|||
|
53d39d5a36
|
|||
|
05bd034b23
|
|||
|
033df03c49
|
|||
|
a0c86f33b4
|
|||
|
712782bc1c
|
|||
|
1c80777fe4
|
|||
|
4c2877403d
|
|||
|
cf2d605077
|
|||
|
c68ead85c0
|
|||
|
b0fd2a4f0c
|
|||
|
a529c1db65
|
|||
|
acac6ed778
|
|||
|
3699daa44a
|
|||
|
4bdd01569c
|
|||
|
33dc52342a
|
|||
|
d3f63c4120
|
|||
|
d36e94127e
|
|||
|
e33d26c6dc
|
|||
|
7702384c40
|
|||
|
f08c60de75
|
|||
|
2e8fd9a22e
|
|||
|
6e86832211
|
|||
|
c7f29c1cd4
|
|||
|
abcc10654b
|
|||
|
3c1797afef
|
|||
|
d006862422
|
|||
|
e60c74a90f
|
|||
|
80fec756a9
|
|||
|
9d1cd01f69
|
|||
|
59e40ed5fd
|
|||
|
692a1d6b2b
|
|||
|
443c25c09b
|
|||
|
4cbc66d9aa
|
|||
|
7ab1c8745f
|
|||
|
13c89cbde2
|
|||
|
c41e0cfc0c
|
|||
|
56c531b64e
|
|||
|
d729924101
|
|||
|
58dd9fb439
|
|||
|
7ef0b9dc7d
|
|||
|
4f18694de3
|
|||
|
285c1cb119
|
|||
|
76da1c3e61
|
|||
|
3da3054587
|
|||
|
64e18f16dd
|
|||
|
3c3837b3f2
|
|||
|
57a6810b03
|
|||
|
395e86f179
|
|||
|
dde1139eed
|
|||
|
bd556d102b
|
|||
|
29bb9872d3
|
|||
|
52f6484db1
|
|||
|
df239fb130
|
|||
|
2345830074
|
|||
|
f9256b70db
|
|||
|
18f4b026ea
|
|||
|
c4ee9d883e
|
|||
|
7aab6df74f
|
|||
|
e0d1abc1e9
|
|||
|
9ae55c92ba
|
|||
|
e5a140fa2e
|
|||
|
e39ccd5939
|
|||
|
44a475dc87
|
|||
|
06799a5088
|
|||
|
77b8172676
|
|||
|
04a59c8396
|
|||
|
604f9d6aba
|
|||
|
c7fb6784c4
|
|||
|
a7f9fbfe90
|
|||
|
320b898b29
|
|||
|
a12fd0a904
|
|||
|
c22aa1036f
|
|||
|
453aeff95a
|
|||
|
19bf98f5b5
|
|||
|
a95200caf9
|
|||
|
ea5ecc3a9f
|
|||
|
05cbc03e82
|
|||
|
9126ce4f61
|
|||
|
fd257e701f
|
|||
|
bd56310067
|
|||
|
fb2a96e94d
|
|||
|
dfb662c646
|
|||
|
21b62a12f9
|
|||
|
b1dba80090
|
|||
|
88f02c6cac
|
|||
|
b0bd27edd0
|
@@ -4,3 +4,7 @@
|
|||||||
data/db/*
|
data/db/*
|
||||||
data/static/avatars/*
|
data/static/avatars/*
|
||||||
!data/static/avatars/default.webp
|
!data/static/avatars/default.webp
|
||||||
|
data/static/badges/user
|
||||||
|
data/_cached
|
||||||
|
|
||||||
|
.local/
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -4,5 +4,10 @@
|
|||||||
data/db/*
|
data/db/*
|
||||||
data/static/avatars/*
|
data/static/avatars/*
|
||||||
!data/static/avatars/default.webp
|
!data/static/avatars/default.webp
|
||||||
|
data/static/badges/user
|
||||||
|
data/_cached
|
||||||
|
|
||||||
config/secrets.prod.env
|
config/secrets.prod.env
|
||||||
|
config/pyrom_config.toml
|
||||||
|
|
||||||
|
.local/
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ RUN apt-get update && apt-get install -y \
|
|||||||
uwsgi \
|
uwsgi \
|
||||||
uwsgi-plugin-python3 \
|
uwsgi-plugin-python3 \
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
libargon2-0 \
|
libargon2-1 \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN python -m venv /opt/venv
|
RUN python -m venv --system-site-packages /opt/venv
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
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.
|
||||||
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Pyrom
|
||||||
|
pyrom is a playful home-grown forum software for the indie web borne out of frustration with social media and modern forums imitating it.
|
||||||
|
|
||||||
|
the aim is not to recreate the feeling of forums from any time period. rather, it aims to serve as a lightweight alternative to other forum software packages. pyrom is lean and "fire-and-forget"; there is little necessary configuration, making it a great fit for smaller communities (though nothing prevents it from being used in larger ones.)
|
||||||
|
|
||||||
|
a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
|
||||||
|
|
||||||
|
## stack & structure
|
||||||
|
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
|
||||||
|
|
||||||
|
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
|
||||||
|
|
||||||
|
below is an explanation of the folder structure:
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `app/`
|
||||||
|
- `lib/` - utility libraries
|
||||||
|
- `routes/` - each `.py` file represents a "sub-app", usually the first part of the URL
|
||||||
|
- `templates/` - Jinja templates used by the routes. each subfolder corresponds to the "sub-app" that uses that template.
|
||||||
|
- `__init__.py` - creates the app
|
||||||
|
- `auth.py` - authentication helper
|
||||||
|
- `constants.py` - constant values used throughout the forum
|
||||||
|
- `db.py` - database abstraction layer and ORM library
|
||||||
|
- `migrations.py` - database migrations
|
||||||
|
- `models.py` - ORM model definitions
|
||||||
|
- `run.py` - runner script for development
|
||||||
|
- `schema.py` - database schema definition
|
||||||
|
- `config/` - configuration for the forum
|
||||||
|
- `data/`
|
||||||
|
- `_cached/` - cached versions of certain endpoints are stored here
|
||||||
|
- `db/` - the SQLite database is stored here
|
||||||
|
- `static/` - static files
|
||||||
|
- `avatars/` - user avatar uploads
|
||||||
|
- `badges/` - user badge uploads
|
||||||
|
- `css/` - CSS files generated from Sass sources
|
||||||
|
- `emoji/` - emoji images used on the forum
|
||||||
|
- `fonts/`
|
||||||
|
- `js/`
|
||||||
|
- `sass/`
|
||||||
|
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
|
||||||
|
- `build-themes.sh` - script for building Sass files into CSS
|
||||||
|
- `nginx.conf` - nginx config (production only)
|
||||||
|
- `uwsgi.ini` - uwsgi config (production only)
|
||||||
|
|
||||||
|
# license
|
||||||
|
released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||||
|
please read the [full terms](./LICENSE.md) for proper wording.
|
||||||
|
|
||||||
|
# acknowledgments
|
||||||
|
|
||||||
|
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
|
||||||
|
|
||||||
|
# installing & first time setup
|
||||||
|
## docker (production)
|
||||||
|
1. clone the repo
|
||||||
|
2. create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
|
||||||
|
3. create `config/pyrom_config.toml` according to `config/pyrom_config.toml.example` and modify as needed
|
||||||
|
4. make sure the `data/` folder is writable by the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ chmod -R 777 data/
|
||||||
|
```
|
||||||
|
|
||||||
|
5. bring up the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- opens port 8080
|
||||||
|
- exposes `data/db` and `data/static` for data backup and persistence
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
6. point your favorite proxy at `localhost:8080`
|
||||||
|
|
||||||
|
## manual (development)
|
||||||
|
1. install python >= 3.13, sqlite3, libargon2, and imagemagick & clone repo
|
||||||
|
2. create a venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python -m venv .venv
|
||||||
|
$ source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. install requirements:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. run dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python -m app.run
|
||||||
|
```
|
||||||
|
|
||||||
|
the server will run on localhost:8080. when run for the first time, it will create an admin account and print its credentials to the terminal, so make sure to run this in an interactive session.
|
||||||
|
|
||||||
|
press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.
|
||||||
|
|
||||||
|
to deactivate the venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
when you want to run the server again, make sure to activate the venv first:
|
||||||
|
```bash
|
||||||
|
$ source .venv/bin/activate
|
||||||
|
$ python -m app.run
|
||||||
|
```
|
||||||
|
|
||||||
105
THIRDPARTY.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Acknowledgments
|
||||||
|
|
||||||
|
## Flask
|
||||||
|
|
||||||
|
URL: https://flask.palletsprojects.com/en/stable/
|
||||||
|
Copyright: `Copyright 2010 Pallets`
|
||||||
|
License: BSD-3-Clause
|
||||||
|
Repo: https://github.com/pallets/flask
|
||||||
|
|
||||||
|
## ChicagoFLF
|
||||||
|
|
||||||
|
Affected files: [`data/static/fonts/ChicagoFLF.woff2`](./data/static/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: [`data/static/fonts/Cadman_Bold.woff2`](./data/static/fonts/Cadman_Bold.woff2) [`data/static/fonts/Cadman_BoldItalic.woff2`](./data/static/fonts/Cadman_BoldItalic.woff2) [`data/static/fonts/Cadman_Italic.woff2`](./data/static/fonts/Cadman_Italic.woff2) [`data/static/fonts/Cadman_Roman.woff2`](./data/static/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
|
||||||
|
|
||||||
|
## Atkinson Hyperlegible Mono
|
||||||
|
Affected files: [`data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf`](./data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf) [`data/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf`](./data/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf)
|
||||||
|
URL: https://www.brailleinstitute.org/freefont/
|
||||||
|
Copyright: Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
|
||||||
|
License: SIL Open Font License 1.1
|
||||||
|
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
||||||
|
|
||||||
|
## Forumoji
|
||||||
|
|
||||||
|
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
|
||||||
|
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)
|
||||||
|
|
||||||
|
## argon2-cffi
|
||||||
|
|
||||||
|
URL: https://github.com/hynek/argon2-cffi
|
||||||
|
Copyright: `Copyright (c) 2015 Hynek Schlawack and the argon2-cffi contributors`
|
||||||
|
License: MIT
|
||||||
|
Repo: https://github.com/hynek/argon2-cffi
|
||||||
|
|
||||||
|
## python-dotenv
|
||||||
|
|
||||||
|
URL: https://github.com/theskumar/python-dotenv
|
||||||
|
Copyright: `Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv)`
|
||||||
|
License: BSD-3-Clause
|
||||||
|
Repo: https://github.com/theskumar/python-dotenv
|
||||||
|
|
||||||
|
## python-slugify
|
||||||
|
|
||||||
|
URL: https://github.com/un33k/python-slugify
|
||||||
|
Copyright: `Copyright (c) Val Neekman @ Neekware Inc. http://neekware.com`
|
||||||
|
License: MIT
|
||||||
|
Repo: https://github.com/un33k/python-slugify
|
||||||
|
|
||||||
|
## Wand
|
||||||
|
|
||||||
|
URL: http://wand-py.org
|
||||||
|
Copyright:
|
||||||
|
|
||||||
|
```
|
||||||
|
Original work Copyright (C) 2011-2018 by Hong Minhee <https://hongminhee.org>
|
||||||
|
Modified work Copyright (C) 2019-2025 by E. McConville <https://emcconville.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
License: MIT
|
||||||
|
Repo: https://github.com/emcconville/wand
|
||||||
|
|
||||||
|
## Bitty
|
||||||
|
|
||||||
|
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
|
||||||
|
URL: https://bitty-js.com/
|
||||||
|
License: CC0 1.0
|
||||||
|
Author: alan w smith https://www.alanwsmith.com/
|
||||||
|
Repo: https://github.com/alanwsmith/bitty
|
||||||
|
|
||||||
|
## Flask-Caching
|
||||||
|
|
||||||
|
URL: https://flask-caching.readthedocs.io/
|
||||||
|
Copyright:
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright (c) 2010 by Thadeus Burgess.
|
||||||
|
Copyright (c) 2016 by Peter Justin.
|
||||||
|
|
||||||
|
Some rights reserved.
|
||||||
|
```
|
||||||
|
|
||||||
|
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
||||||
|
Repo: https://github.com/pallets-eco/flask-caching
|
||||||
|
|
||||||
|
# Legacy
|
||||||
|
|
||||||
|
this section lists credits for files/libraries that are no longer used by the project.
|
||||||
|
|
||||||
|
## ICONCINO
|
||||||
|
|
||||||
|
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||||
|
Designers: Gabriele Malaspina
|
||||||
|
License: CC0 1.0
|
||||||
344
app/__init__.py
@@ -1,28 +1,354 @@
|
|||||||
from flask import Flask
|
from flask import Flask, session, request, render_template, redirect, url_for
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
||||||
|
from .auth import digest, is_logged_in, get_active_user
|
||||||
|
from .constants import (
|
||||||
|
PermissionLevel, permission_level_string,
|
||||||
|
InfoboxKind, InfoboxHTMLClass,
|
||||||
|
REACTION_EMOJI, MOTD_BANNED_TAGS,
|
||||||
|
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
|
||||||
|
)
|
||||||
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||||
|
from .lib.exceptions import SiteNameMissingException
|
||||||
|
from .util import get_post_url, dict_to_query_string, csrf_input, get_csrf_token
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask_caching import Cache
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
import tomllib
|
||||||
|
import json
|
||||||
|
|
||||||
|
def create_default_avatar():
|
||||||
|
if Avatars.count() == 0:
|
||||||
|
print('Creating default avatar reference')
|
||||||
|
Avatars.create({
|
||||||
|
'file_path': '/static/avatars/default.webp',
|
||||||
|
'uploaded_at': int(time.time())
|
||||||
|
})
|
||||||
|
|
||||||
|
def create_admin():
|
||||||
|
username = 'admin'
|
||||||
|
if Users.count({'username': username}) == 0:
|
||||||
|
print('!!!!!Creating admin account!!!!!')
|
||||||
|
password_length = 16
|
||||||
|
password = secrets.token_urlsafe(password_length)
|
||||||
|
hashed = digest(password)
|
||||||
|
Users.create({
|
||||||
|
'username': username,
|
||||||
|
'password_hash': hashed,
|
||||||
|
'permission': PermissionLevel.ADMIN.value,
|
||||||
|
})
|
||||||
|
print(f"!!!!!Administrator account created, use '{username}' as the login and '{password}' as the password. This will only be shown once!!!!!")
|
||||||
|
|
||||||
|
def create_deleted_user():
|
||||||
|
username = 'DeletedUser'
|
||||||
|
if Users.count({'username': username.lower()}) == 0:
|
||||||
|
print('Creating DeletedUser')
|
||||||
|
Users.create({
|
||||||
|
'username': username.lower(),
|
||||||
|
'display_name': username,
|
||||||
|
'password_hash': '',
|
||||||
|
'permission': PermissionLevel.SYSTEM.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
def reparse_babycode():
|
||||||
|
print('Re-parsing babycode, this may take a while...')
|
||||||
|
from .db import db
|
||||||
|
from .constants import MOTD_BANNED_TAGS
|
||||||
|
|
||||||
|
post_histories_without_rss = PostHistory.findall([
|
||||||
|
('markup_language', '=', 'babycode'),
|
||||||
|
('content_rss', 'IS', None),
|
||||||
|
])
|
||||||
|
|
||||||
|
with db.transaction():
|
||||||
|
for ph in post_histories_without_rss:
|
||||||
|
ph.update({
|
||||||
|
'content_rss': babycode_to_rssxml(ph['original_markup']),
|
||||||
|
})
|
||||||
|
|
||||||
|
post_histories = PostHistory.findall([
|
||||||
|
('markup_language', '=', 'babycode'),
|
||||||
|
('format_version', 'IS NOT', BABYCODE_VERSION)
|
||||||
|
])
|
||||||
|
if len(post_histories) > 0:
|
||||||
|
print('Re-parsing user posts...')
|
||||||
|
with db.transaction():
|
||||||
|
for ph in post_histories:
|
||||||
|
ph.update({
|
||||||
|
'content': babycode_to_html(ph['original_markup']).result,
|
||||||
|
'content_rss': babycode_to_rssxml(ph['original_markup']),
|
||||||
|
'format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
print('Re-parsing posts done.')
|
||||||
|
|
||||||
|
users_with_sigs = Users.findall([
|
||||||
|
('signature_markup_language', '=', 'babycode'),
|
||||||
|
('signature_format_version', 'IS NOT', BABYCODE_VERSION),
|
||||||
|
('signature_original_markup', 'IS NOT', '')
|
||||||
|
])
|
||||||
|
if len(users_with_sigs) > 0:
|
||||||
|
print('Re-parsing user sigs...')
|
||||||
|
with db.transaction():
|
||||||
|
for user in users_with_sigs:
|
||||||
|
user.update({
|
||||||
|
'signature_rendered': babycode_to_html(user['signature_original_markup']).result,
|
||||||
|
'signature_format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
print(f'Re-parsed {len(users_with_sigs)} user sigs.')
|
||||||
|
|
||||||
|
stale_motds = MOTD.findall([
|
||||||
|
['markup_language', '=', 'babycode'],
|
||||||
|
['format_version', 'IS NOT', BABYCODE_VERSION]
|
||||||
|
])
|
||||||
|
if stale_motds:
|
||||||
|
print('Re-parsing MOTDs...')
|
||||||
|
with db.transaction():
|
||||||
|
for motd in stale_motds:
|
||||||
|
motd.update({
|
||||||
|
'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS).result,
|
||||||
|
'format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
print('Re-parsing MOTDs done.')
|
||||||
|
|
||||||
|
print('Re-parsing done.')
|
||||||
|
|
||||||
|
def bind_default_badges(path):
|
||||||
|
from .db import db
|
||||||
|
with db.transaction():
|
||||||
|
potential_stales = BadgeUploads.get_default()
|
||||||
|
d = os.listdir(path)
|
||||||
|
for bu in potential_stales:
|
||||||
|
if os.path.basename(bu.file_path) not in d:
|
||||||
|
print(f'Deleted stale default badge{os.path.basename(bu.file_path)}')
|
||||||
|
bu.delete()
|
||||||
|
|
||||||
|
for f in d:
|
||||||
|
real_path = os.path.join(path, f)
|
||||||
|
if not os.path.isfile(real_path):
|
||||||
|
continue
|
||||||
|
if not f.endswith('.webp'):
|
||||||
|
continue
|
||||||
|
proxied_path = f'/static/badges/{f}'
|
||||||
|
bu = BadgeUploads.find({'file_path': proxied_path})
|
||||||
|
if not bu:
|
||||||
|
BadgeUploads.create({
|
||||||
|
'file_path': proxied_path,
|
||||||
|
'uploaded_at': int(os.path.getmtime(real_path)),
|
||||||
|
})
|
||||||
|
|
||||||
|
def clear_stale_sessions():
|
||||||
|
from .db import db
|
||||||
|
with db.transaction():
|
||||||
|
now = int(time.time())
|
||||||
|
stale_sessions = Sessions.findall([
|
||||||
|
('expires_at', '<', now)
|
||||||
|
])
|
||||||
|
for sess in stale_sessions:
|
||||||
|
sess.delete()
|
||||||
|
|
||||||
|
|
||||||
|
cache = Cache()
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.config['SITE_NAME'] = 'Pyrom'
|
||||||
|
app.config['DISABLE_SIGNUP'] = False
|
||||||
|
app.config['MODS_CAN_INVITE'] = True
|
||||||
|
app.config['USERS_CAN_INVITE'] = False
|
||||||
|
app.config['ADMIN_CONTACT_INFO'] = ''
|
||||||
|
app.config['GUIDE_DESCRIPTION'] = ''
|
||||||
|
|
||||||
if os.getenv("PYROM_PROD") is None:
|
app.config['CACHE_TYPE'] = 'FileSystemCache'
|
||||||
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
|
app.config['CACHE_DEFAULT_TIMEOUT'] = 300
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print('No configuration file found, leaving defaults.')
|
||||||
|
|
||||||
|
if os.getenv('PYROM_PROD') is None:
|
||||||
|
app.static_folder = os.path.join(os.path.dirname(__file__), '../data/static')
|
||||||
app.debug = True
|
app.debug = True
|
||||||
app.config["DB_PATH"] = "data/db/db.dev.sqlite"
|
app.config['DB_PATH'] = 'data/db/db.dev.sqlite'
|
||||||
|
app.config['SERVER_NAME'] = 'localhost:8080'
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
else:
|
else:
|
||||||
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
|
app.config['DB_PATH'] = 'data/db/db.prod.sqlite'
|
||||||
|
if not app.config['SERVER_NAME']:
|
||||||
|
raise SiteNameMissingException()
|
||||||
|
|
||||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
|
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY')
|
||||||
|
|
||||||
|
app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
|
||||||
|
app.config['BADGES_PATH'] = 'data/static/badges/'
|
||||||
|
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 3 * 1000 * 1000 # 3M total, subject to further limits per route
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(app.config['DB_PATH']), exist_ok = True)
|
||||||
|
os.makedirs(os.path.dirname(app.config['BADGES_UPLOAD_PATH']), exist_ok = True)
|
||||||
|
|
||||||
|
if app.config['CACHE_TYPE'] == 'FileSystemCache':
|
||||||
|
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
|
||||||
|
os.makedirs(cache_dir, exist_ok = True)
|
||||||
|
app.config['CACHE_DIR'] = cache_dir
|
||||||
|
|
||||||
|
cache.init_app(app)
|
||||||
|
|
||||||
|
from app.routes.app import bp as app_bp
|
||||||
|
from app.routes.topics import bp as topics_bp
|
||||||
|
from app.routes.threads import bp as threads_bp
|
||||||
|
from app.routes.users import bp as users_bp
|
||||||
|
from app.routes.guides import bp as guides_bp
|
||||||
|
from app.routes.mod import bp as mod_bp
|
||||||
|
from app.routes.posts import bp as posts_bp
|
||||||
|
app.register_blueprint(app_bp)
|
||||||
|
app.register_blueprint(topics_bp)
|
||||||
|
app.register_blueprint(threads_bp)
|
||||||
|
app.register_blueprint(users_bp)
|
||||||
|
app.register_blueprint(guides_bp)
|
||||||
|
app.register_blueprint(mod_bp)
|
||||||
|
app.register_blueprint(posts_bp)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from .schema import create as create_tables
|
from .schema import create as create_tables
|
||||||
from .migrations import run_migrations
|
from .migrations import run_migrations
|
||||||
create_tables()
|
create_tables()
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
|
||||||
from app.routes.app import bp as app_bp
|
create_default_avatar()
|
||||||
app.register_blueprint(app_bp)
|
create_admin()
|
||||||
|
create_deleted_user()
|
||||||
|
|
||||||
|
clear_stale_sessions()
|
||||||
|
|
||||||
|
reparse_babycode()
|
||||||
|
|
||||||
|
bind_default_badges(app.config['BADGES_PATH'])
|
||||||
|
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def revoke_session():
|
||||||
|
if is_logged_in():
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
if int(time.time()) > int(sess.expires_at):
|
||||||
|
sess.delete()
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def generate_csrf_token():
|
||||||
|
if is_logged_in() and not session.get('csrf'):
|
||||||
|
rng = secrets.token_bytes(32)
|
||||||
|
session_key = session['pyrom_session_key']
|
||||||
|
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||||
|
hashed = hmac.digest(app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256')
|
||||||
|
csrf_token = f'{hashed.hex()}.{rng.hex()}'
|
||||||
|
|
||||||
|
session['csrf'] = csrf_token
|
||||||
|
|
||||||
|
commit = ''
|
||||||
|
with open('.git/refs/heads/main') as f:
|
||||||
|
commit = f.read().strip()
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_constants():
|
||||||
|
return {
|
||||||
|
'InfoboxHTMLClass': InfoboxHTMLClass,
|
||||||
|
'InfoboxKind': InfoboxKind,
|
||||||
|
'PermissionLevel': PermissionLevel,
|
||||||
|
'__commit': commit,
|
||||||
|
'__emoji': EMOJI,
|
||||||
|
'REACTION_EMOJI': REACTION_EMOJI,
|
||||||
|
'MOTD_BANNED_TAGS': MOTD_BANNED_TAGS,
|
||||||
|
'SIG_BANNED_TAGS': SIG_BANNED_TAGS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_funcs():
|
||||||
|
return {
|
||||||
|
'get_motds': MOTD.get_all,
|
||||||
|
'get_time_now': lambda: int(time.time()),
|
||||||
|
'is_logged_in': is_logged_in,
|
||||||
|
'is_mod': lambda: is_logged_in() and get_active_user().is_mod(),
|
||||||
|
'get_active_user': get_active_user,
|
||||||
|
'get_post_url': get_post_url,
|
||||||
|
'csrf_input': csrf_input,
|
||||||
|
'get_csrf_token': get_csrf_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.template_filter('ts_datetime')
|
||||||
|
def ts_datetime(ts, format):
|
||||||
|
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
||||||
|
|
||||||
|
@app.template_filter('dict_to_query_string')
|
||||||
|
def d2q(d):
|
||||||
|
return dict_to_query_string(d)
|
||||||
|
|
||||||
|
@app.template_filter('pluralize')
|
||||||
|
def pluralize(subject, num=1, singular = '', plural = 's'):
|
||||||
|
if int(num) == 1:
|
||||||
|
return subject + singular
|
||||||
|
|
||||||
|
return subject + plural
|
||||||
|
|
||||||
|
@app.template_filter('permission_string')
|
||||||
|
def permission_string(term):
|
||||||
|
return permission_level_string(term)
|
||||||
|
|
||||||
|
@app.template_filter('babycode')
|
||||||
|
def babycode_filter(markup, nofrag=False):
|
||||||
|
return babycode_to_html(markup, fragment=not nofrag).result
|
||||||
|
|
||||||
|
@app.template_filter('babycode_strict')
|
||||||
|
def babycode_strict_filter(markup, nofrag=False):
|
||||||
|
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
|
||||||
|
|
||||||
|
@app.template_filter('basename_noext')
|
||||||
|
def basename_noext(subj):
|
||||||
|
return os.path.splitext(os.path.basename(subj))[0]
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def _handle_404(e):
|
||||||
|
if request.path.startswith('/hyperapi/'):
|
||||||
|
return '<h1>not found</h1>', e.code
|
||||||
|
elif request.path.startswith('/api/'):
|
||||||
|
return {'error': 'not found'}, e.code
|
||||||
|
else:
|
||||||
|
return render_template('common/404.html'), e.code
|
||||||
|
#
|
||||||
|
# @app.errorhandler(413)
|
||||||
|
# def _handle_413(e):
|
||||||
|
# if request.path.startswith('/hyperapi/'):
|
||||||
|
# return '<h1>request body too large</h1>', e.code
|
||||||
|
# elif request.path.startswith('/api/'):
|
||||||
|
# return {'error': 'body too large'}, e.code
|
||||||
|
# else:
|
||||||
|
# return render_template('common/413.html'), e.code
|
||||||
|
|
||||||
|
# this only happens at build time but
|
||||||
|
# build time is when updates are done anyway
|
||||||
|
# sooo... /shrug
|
||||||
|
@app.template_filter('cachebust')
|
||||||
|
def cachebust(subject):
|
||||||
|
return f'{subject}?v={str(int(time.time()))}'
|
||||||
|
|
||||||
|
@app.template_filter('theme_name')
|
||||||
|
def get_theme_name(subject: str):
|
||||||
|
if subject == 'style':
|
||||||
|
return 'Default'
|
||||||
|
|
||||||
|
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
|
||||||
|
|
||||||
|
@app.template_filter('fromjson')
|
||||||
|
def fromjson(subject: str):
|
||||||
|
return json.loads(subject)
|
||||||
|
|
||||||
|
@app.template_filter('iso8601')
|
||||||
|
def unix_to_iso8601(subject: str):
|
||||||
|
return datetime.fromtimestamp(int(subject), timezone.utc).isoformat()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
114
app/auth.py
@@ -1,8 +1,26 @@
|
|||||||
|
from flask import session, flash, redirect, url_for, abort, request, current_app
|
||||||
|
from .models import Sessions, Users
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
|
from functools import wraps
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
def hash_password(password):
|
FORBIDDEN_USERNAMES = (
|
||||||
|
'administrator', 'administration', 'administrators',
|
||||||
|
'system',
|
||||||
|
'mod', 'moderator', 'moderators', 'moderation',
|
||||||
|
'deleted-user', 'deleted_user',
|
||||||
|
'support',
|
||||||
|
#routes
|
||||||
|
'log-in', 'log_in', 'login',
|
||||||
|
'sign-up', 'sign_up', 'signup',
|
||||||
|
)
|
||||||
|
|
||||||
|
def digest(password):
|
||||||
return ph.hash(password)
|
return ph.hash(password)
|
||||||
|
|
||||||
def verify(expected, given):
|
def verify(expected, given):
|
||||||
@@ -10,3 +28,97 @@ def verify(expected, given):
|
|||||||
return ph.verify(expected, given)
|
return ph.verify(expected, given)
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_logged_in() -> bool:
|
||||||
|
if 'pyrom_session_key' not in session:
|
||||||
|
return False
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
if sess.expires_at < int(time.time()):
|
||||||
|
session.clear()
|
||||||
|
sess.delete()
|
||||||
|
# flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_active_user() -> Users | None:
|
||||||
|
if not is_logged_in():
|
||||||
|
return None
|
||||||
|
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
return Users.find({'id': sess.user_id})
|
||||||
|
|
||||||
|
def create_session(user_id, temporary=False):
|
||||||
|
expires_days = 2 if temporary else 31
|
||||||
|
return Sessions.create({
|
||||||
|
'key': secrets.token_hex(16),
|
||||||
|
'user_id': user_id,
|
||||||
|
'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60),
|
||||||
|
})
|
||||||
|
|
||||||
|
def parse_username(username: str) -> Tuple[str, str]:
|
||||||
|
"""first is the unmodified name/display name, second is username"""
|
||||||
|
if len(username) < 3:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
if username.lower() in FORBIDDEN_USERNAMES:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
invalid_regex = r'[^a-zA-Z0-9_-]'
|
||||||
|
return re.sub(invalid_regex, '_', username.lower())[:24], username
|
||||||
|
|
||||||
|
def is_password_valid(password: str) -> bool:
|
||||||
|
return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None
|
||||||
|
|
||||||
|
# annotations
|
||||||
|
def login_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
return redirect(url_for('users.log_in'))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def mod_only(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(403)
|
||||||
|
if not get_active_user().is_mod():
|
||||||
|
abort(403)
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def csrf_verified(view_func):
|
||||||
|
"""
|
||||||
|
protects a request with a form against csrf and invalidates the csrf token stored in the session.
|
||||||
|
|
||||||
|
requires @login_requred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not session.get('csrf'):
|
||||||
|
abort(403)
|
||||||
|
if not request.form.get('csrf'):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
parts = request.form['csrf'].split('.')
|
||||||
|
if len(parts) != 2:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
given_message = parts[0]
|
||||||
|
rng = bytes.fromhex(parts[1])
|
||||||
|
session_key = session['pyrom_session_key']
|
||||||
|
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||||
|
expected = hmac.digest(current_app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256').hex()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(given_message, expected):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
session.pop('csrf')
|
||||||
|
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|||||||
77
app/constants.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from enum import Enum, IntEnum
|
||||||
|
|
||||||
|
class PermissionLevel(Enum):
|
||||||
|
GUEST = 0
|
||||||
|
USER = 1
|
||||||
|
MODERATOR = 2
|
||||||
|
SYSTEM = 3
|
||||||
|
ADMIN = 4
|
||||||
|
|
||||||
|
PermissionLevelString = {
|
||||||
|
PermissionLevel.GUEST: 'Guest',
|
||||||
|
PermissionLevel.USER: 'User',
|
||||||
|
PermissionLevel.MODERATOR: 'Moderator',
|
||||||
|
PermissionLevel.SYSTEM: 'System',
|
||||||
|
PermissionLevel.ADMIN: 'Administrator',
|
||||||
|
}
|
||||||
|
|
||||||
|
REACTION_EMOJI = [
|
||||||
|
'smile',
|
||||||
|
'grin',
|
||||||
|
|
||||||
|
'neutral',
|
||||||
|
|
||||||
|
'wink',
|
||||||
|
|
||||||
|
'frown',
|
||||||
|
'angry',
|
||||||
|
|
||||||
|
'think',
|
||||||
|
|
||||||
|
'sob',
|
||||||
|
|
||||||
|
'surprised',
|
||||||
|
|
||||||
|
'smiletear',
|
||||||
|
|
||||||
|
'tongue',
|
||||||
|
|
||||||
|
'pensive',
|
||||||
|
'weary',
|
||||||
|
|
||||||
|
'imp',
|
||||||
|
'impangry',
|
||||||
|
|
||||||
|
'lobster',
|
||||||
|
|
||||||
|
'scissors',
|
||||||
|
]
|
||||||
|
|
||||||
|
MOTD_BANNED_TAGS = [
|
||||||
|
'img', 'spoiler', '@mention',
|
||||||
|
]
|
||||||
|
|
||||||
|
SIG_BANNED_TAGS = [
|
||||||
|
'@mention',
|
||||||
|
]
|
||||||
|
|
||||||
|
STRICT_BANNED_TAGS = [
|
||||||
|
'img', 'spoiler', '@mention',
|
||||||
|
'big', 'small', 'center', 'right', 'color',
|
||||||
|
]
|
||||||
|
|
||||||
|
def permission_level_string(perm):
|
||||||
|
return PermissionLevelString[PermissionLevel(int(perm))]
|
||||||
|
|
||||||
|
class InfoboxKind(IntEnum):
|
||||||
|
INFO = 0
|
||||||
|
LOCK = 1
|
||||||
|
WARN = 2
|
||||||
|
ERROR = 3
|
||||||
|
|
||||||
|
InfoboxHTMLClass = {
|
||||||
|
InfoboxKind.INFO: '',
|
||||||
|
InfoboxKind.LOCK: 'warn',
|
||||||
|
InfoboxKind.WARN: 'warn',
|
||||||
|
InfoboxKind.ERROR: 'critical',
|
||||||
|
}
|
||||||
181
app/db.py
@@ -4,13 +4,13 @@ from flask import current_app
|
|||||||
|
|
||||||
class DB:
|
class DB:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._transaction_depth = 0
|
|
||||||
self._connection = None
|
self._connection = None
|
||||||
|
self._transaction_depth = 0
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _get_connection(self):
|
def connection(self, in_transaction = False):
|
||||||
if self._connection and self._transaction_depth > 0:
|
if self._connection:
|
||||||
yield self._connection
|
yield self._connection
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -19,60 +19,44 @@ class DB:
|
|||||||
conn.execute("PRAGMA FOREIGN_KEYS = 1")
|
conn.execute("PRAGMA FOREIGN_KEYS = 1")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if in_transaction:
|
||||||
|
self._connection = conn
|
||||||
|
self._transaction_depth += 1
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
yield conn
|
yield conn
|
||||||
|
|
||||||
|
if in_transaction:
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
if in_transaction and self._connection:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
if self._transaction_depth == 0:
|
if in_transaction:
|
||||||
conn.close()
|
self._transaction_depth -= 1
|
||||||
|
if self._transaction_depth == 0:
|
||||||
|
self._connection = None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(self):
|
def transaction(self):
|
||||||
"""Transaction context."""
|
"""Transaction context."""
|
||||||
self.begin()
|
with self.connection(in_transaction=True) as conn:
|
||||||
try:
|
yield conn
|
||||||
yield
|
|
||||||
self.commit()
|
|
||||||
except Exception:
|
|
||||||
self.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def begin(self):
|
|
||||||
"""Begins a new transaction."""
|
|
||||||
if self._transaction_depth == 0:
|
|
||||||
if not self._connection:
|
|
||||||
self._connection = sqlite3.connect(current_app.config["DB_PATH"])
|
|
||||||
self._connection.row_factory = sqlite3.Row
|
|
||||||
self._connection.execute("PRAGMA FOREIGN_KEYS = 1")
|
|
||||||
self._connection.execute("BEGIN")
|
|
||||||
self._transaction_depth += 1
|
|
||||||
|
|
||||||
|
|
||||||
def commit(self):
|
|
||||||
"""Commits the current transaction."""
|
|
||||||
if self._transaction_depth > 0:
|
|
||||||
self._transaction_depth -= 1
|
|
||||||
if self._transaction_depth == 0:
|
|
||||||
self._connection.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def rollback(self):
|
|
||||||
"""Rolls back the current transaction."""
|
|
||||||
if self._transaction_depth > 0:
|
|
||||||
self._transaction_depth = 0
|
|
||||||
self._connection.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
def query(self, sql, *args):
|
def query(self, sql, *args):
|
||||||
"""Executes a query and returns a list of dictionaries."""
|
"""Executes a query and returns a list of dictionaries."""
|
||||||
with self._get_connection() as conn:
|
with self.connection() as conn:
|
||||||
rows = conn.execute(sql, args).fetchall()
|
rows = conn.execute(sql, args).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def insert(self, table, columns, *values):
|
def insert(self, table, columns, *values):
|
||||||
if isinstance(columns, (list, tuple)):
|
if isinstance(columns, (list, tuple)):
|
||||||
columns = ", ".join(columns)
|
columns = ", ".join([f'"{column}"' for column in columns])
|
||||||
|
|
||||||
placeholders = ", ".join(["?"] * len(values))
|
placeholders = ", ".join(["?"] * len(values))
|
||||||
sql = f"""
|
sql = f"""
|
||||||
@@ -81,7 +65,7 @@ class DB:
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with self._get_connection() as conn:
|
with self.connection() as conn:
|
||||||
result = conn.execute(sql, values).fetchone()
|
result = conn.execute(sql, values).fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return dict(result) if result else None
|
return dict(result) if result else None
|
||||||
@@ -89,14 +73,14 @@ class DB:
|
|||||||
|
|
||||||
def execute(self, sql, *args):
|
def execute(self, sql, *args):
|
||||||
"""Executes a query without returning."""
|
"""Executes a query without returning."""
|
||||||
with self._get_connection() as conn:
|
with self.connection() as conn:
|
||||||
conn.execute(sql, args)
|
conn.execute(sql, args)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def fetch_one(self, sql, *args):
|
def fetch_one(self, sql, *args):
|
||||||
"""Grabs the first row of a query."""
|
"""Grabs the first row of a query."""
|
||||||
with self._get_connection() as conn:
|
with self.connection() as conn:
|
||||||
row = conn.execute(sql, args).fetchone()
|
row = conn.execute(sql, args).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
@@ -104,9 +88,35 @@ class DB:
|
|||||||
class QueryBuilder:
|
class QueryBuilder:
|
||||||
def __init__(self, table):
|
def __init__(self, table):
|
||||||
self.table = table
|
self.table = table
|
||||||
self._where = {}
|
self._where = [] # list of tuples
|
||||||
self._select = "*"
|
self._select = "*"
|
||||||
self._params = []
|
self._group_by = ""
|
||||||
|
self._order_by = ""
|
||||||
|
self._order_asc = True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_where(self):
|
||||||
|
if not self._where:
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
for col, op, val in self._where:
|
||||||
|
conditions.append(f"{col} {op} ?")
|
||||||
|
params.append(val)
|
||||||
|
|
||||||
|
return " WHERE " + " AND ".join(conditions), params
|
||||||
|
|
||||||
|
|
||||||
|
def group_by(self, stmt):
|
||||||
|
self._group_by = stmt
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def order_by(self, stmt, asc = True):
|
||||||
|
self._order_by = stmt
|
||||||
|
self._order_asc = asc
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
def select(self, columns = "*"):
|
def select(self, columns = "*"):
|
||||||
@@ -114,40 +124,47 @@ class DB:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
def where(self, condition):
|
def where(self, condition, operator = "="):
|
||||||
self._where.update(condition)
|
if isinstance(condition, dict):
|
||||||
|
for key, value in condition.items():
|
||||||
|
self._where.append((key, operator, value))
|
||||||
|
elif isinstance(condition, list):
|
||||||
|
for c in condition:
|
||||||
|
self._where.append(c)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
def build_select(self):
|
def build_select(self):
|
||||||
sql = f"SELECT {self._select} FROM {self.table}"
|
sql = f"SELECT {self._select} FROM {self.table}"
|
||||||
if self._where:
|
where_clause, params = self._build_where()
|
||||||
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
|
|
||||||
sql += f" WHERE {conditions}"
|
stmt = sql + where_clause
|
||||||
return sql, list(self._where.values())
|
|
||||||
|
if self._group_by:
|
||||||
|
stmt += " GROUP BY " + self._group_by
|
||||||
|
|
||||||
|
if self._order_by:
|
||||||
|
stmt += " ORDER BY " + self._order_by + (" ASC" if self._order_asc else " DESC")
|
||||||
|
|
||||||
|
return stmt, params
|
||||||
|
|
||||||
|
|
||||||
def build_update(self, data):
|
def build_update(self, data):
|
||||||
columns = ", ".join(f"{k} = ?" for k in data.keys())
|
columns = ", ".join(f"{k} = ?" for k in data.keys())
|
||||||
sql = f"UPDATE {self.table} SET {columns}"
|
sql = f"UPDATE {self.table} SET {columns}"
|
||||||
if self._where:
|
where_clause, where_params = self._build_where()
|
||||||
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
|
params = list(data.values()) + list(where_params)
|
||||||
sql += f" WHERE {conditions}"
|
return sql + where_clause, params
|
||||||
params = list(data.values()) + list(self._where.values())
|
|
||||||
return sql, params
|
|
||||||
|
|
||||||
|
|
||||||
def build_delete(self):
|
def build_delete(self):
|
||||||
sql = f"DELETE FROM {self.table}"
|
sql = f"DELETE FROM {self.table}"
|
||||||
if self._where:
|
where_clause, params = self._build_where()
|
||||||
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
|
return sql + where_clause, params
|
||||||
sql += f" WHERE {conditions}"
|
|
||||||
return sql, list(self._where.values())
|
|
||||||
|
|
||||||
|
|
||||||
def first(self):
|
def first(self):
|
||||||
sql, params = self.build_select()
|
sql, params = self.build_select()
|
||||||
print(sql, params)
|
|
||||||
return db.fetch_one(f"{sql} LIMIT 1", *params)
|
return db.fetch_one(f"{sql} LIMIT 1", *params)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +190,13 @@ class Model:
|
|||||||
raise AttributeError(f"No column '{key}'")
|
raise AttributeError(f"No column '{key}'")
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_data(cls, data):
|
||||||
|
instance = cls(cls.table)
|
||||||
|
instance._data = dict(data)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, condition):
|
def find(cls, condition):
|
||||||
row = db.QueryBuilder(cls.table)\
|
row = db.QueryBuilder(cls.table)\
|
||||||
@@ -180,9 +204,16 @@ class Model:
|
|||||||
.first()
|
.first()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
instance = cls(cls.table)
|
return cls.from_data(row)
|
||||||
instance._data = dict(row)
|
|
||||||
return instance
|
|
||||||
|
@classmethod
|
||||||
|
def findall(cls, condition, operator='='):
|
||||||
|
rows = db.QueryBuilder(cls.table)\
|
||||||
|
.where(condition, operator)\
|
||||||
|
.all()
|
||||||
|
res = []
|
||||||
|
return [cls.from_data(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -194,12 +225,28 @@ class Model:
|
|||||||
row = db.insert(cls.table, columns, *values.values())
|
row = db.insert(cls.table, columns, *values.values())
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
instance = cls(cls.table)
|
return cls.from_data(row)
|
||||||
instance._data = row
|
|
||||||
return instance
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def count(cls, conditions = None):
|
||||||
|
qb = db.QueryBuilder(cls.table).select("COUNT(*) AS c")
|
||||||
|
if conditions is not None:
|
||||||
|
qb.where(conditions)
|
||||||
|
|
||||||
|
result = qb.first()
|
||||||
|
return result["c"] if result else 0
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def select(cls, sel = "*"):
|
||||||
|
qb = db.QueryBuilder(cls.table).select(sel)
|
||||||
|
result = qb.all()
|
||||||
|
# return result if result else []
|
||||||
|
return [cls.from_data(data) for data in (result if result else [])]
|
||||||
|
|
||||||
|
|
||||||
def update(self, data):
|
def update(self, data):
|
||||||
qb = db.QueryBuilder(self.table)\
|
qb = db.QueryBuilder(self.table)\
|
||||||
.where({"id": self._data["id"]})
|
.where({"id": self._data["id"]})
|
||||||
|
|||||||
610
app/lib/babycode.py
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
from .babycode_parser import Parser
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
from pygments import highlight
|
||||||
|
from pygments.formatters import HtmlFormatter
|
||||||
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||||
|
import re
|
||||||
|
|
||||||
|
BABYCODE_VERSION = 10
|
||||||
|
|
||||||
|
|
||||||
|
class BabycodeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BabycodeRenderError(BabycodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownASTElementError(BabycodeRenderError):
|
||||||
|
def __init__(self, element_type, element=None):
|
||||||
|
self.element_type = element_type
|
||||||
|
self.element = element
|
||||||
|
|
||||||
|
message = f'Unknown AST element: {element_type}'
|
||||||
|
if element:
|
||||||
|
message += f' (element: {element})'
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class BabycodeRenderResult:
|
||||||
|
def __init__(self, result, mentions=[]):
|
||||||
|
self.result = result
|
||||||
|
self.mentions = mentions
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
class BabycodeRenderer:
|
||||||
|
def __init__(self, tag_map, void_tag_map, emote_map, fragment=False):
|
||||||
|
self.tag_map = tag_map
|
||||||
|
self.void_tag_map = void_tag_map
|
||||||
|
self.emote_map = emote_map
|
||||||
|
self.fragment = fragment
|
||||||
|
|
||||||
|
def make_mention(self, element):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def transform_para_whitespace(self, text):
|
||||||
|
# markdown rules:
|
||||||
|
# two spaces at end of line -> <br>
|
||||||
|
text = re.sub(r' +\n', '<br>', text)
|
||||||
|
# single newlines -> space (collapsed)
|
||||||
|
text = re.sub(r'\n', ' ', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def wrap_in_paragraphs(self, nodes, context_is_block=True, is_root=False):
|
||||||
|
result = []
|
||||||
|
current_paragraph = []
|
||||||
|
is_first_para = is_root and self.fragment
|
||||||
|
|
||||||
|
def flush_paragraph():
|
||||||
|
# TIL nonlocal exists
|
||||||
|
nonlocal result, current_paragraph, is_first_para
|
||||||
|
if not current_paragraph:
|
||||||
|
return
|
||||||
|
|
||||||
|
para_content = ''.join(current_paragraph)
|
||||||
|
if para_content.strip(): # skip empty paragraphs
|
||||||
|
if is_first_para:
|
||||||
|
result.append(para_content)
|
||||||
|
is_first_para = False
|
||||||
|
else:
|
||||||
|
result.append(f"<p>{para_content}</p>")
|
||||||
|
current_paragraph.clear()
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
if isinstance(node, str):
|
||||||
|
paras = re.split(r'\n\n+', node)
|
||||||
|
for i, para in enumerate(paras):
|
||||||
|
if i > 0 and context_is_block:
|
||||||
|
flush_paragraph()
|
||||||
|
|
||||||
|
if para:
|
||||||
|
processed = self.transform_para_whitespace(para)
|
||||||
|
current_paragraph.append(processed)
|
||||||
|
else:
|
||||||
|
inline = is_inline(node)
|
||||||
|
|
||||||
|
if inline and context_is_block:
|
||||||
|
# inline child within a paragraph context
|
||||||
|
current_paragraph.append(self.fold(node))
|
||||||
|
elif not inline and context_is_block:
|
||||||
|
# block child within a block context
|
||||||
|
flush_paragraph()
|
||||||
|
if is_root:
|
||||||
|
# this is relevant for fragment.
|
||||||
|
# fragment only applies to the first inline node(s).
|
||||||
|
# if the first element is a block, reset "fragment mode".
|
||||||
|
is_first_para = False
|
||||||
|
result.append(self.fold(node))
|
||||||
|
else:
|
||||||
|
# either inline in inline context, or block in inline context
|
||||||
|
current_paragraph.append(self.fold(node))
|
||||||
|
|
||||||
|
if context_is_block:
|
||||||
|
# flush final para if we're in a block context
|
||||||
|
flush_paragraph()
|
||||||
|
elif current_paragraph:
|
||||||
|
# inline context - just append whatever we collected
|
||||||
|
result.append(''.join(current_paragraph))
|
||||||
|
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
def fold(self, element):
|
||||||
|
if isinstance(element, str):
|
||||||
|
return element
|
||||||
|
|
||||||
|
match element['type']:
|
||||||
|
case 'bbcode':
|
||||||
|
tag_name = element['name']
|
||||||
|
|
||||||
|
if is_inline(element):
|
||||||
|
# inline tag
|
||||||
|
# since its inline, all children should be processed inline
|
||||||
|
content = "".join(self.fold(child) for child in element['children'])
|
||||||
|
return self.tag_map[tag_name](content, element['attr'])
|
||||||
|
else:
|
||||||
|
# block tag
|
||||||
|
if tag_name in {'ul', 'ol', 'code', 'img'}:
|
||||||
|
# these handle their own internal structure
|
||||||
|
content = ''.join(
|
||||||
|
child if isinstance(child, str) else self.fold(child)
|
||||||
|
for child in element['children']
|
||||||
|
)
|
||||||
|
return self.tag_map[tag_name](content, element['attr'])
|
||||||
|
else:
|
||||||
|
# block elements that can contain paragraphs
|
||||||
|
content = self.wrap_in_paragraphs(element['children'], context_is_block=True, is_root=False)
|
||||||
|
return self.tag_map[tag_name](content, element['attr'])
|
||||||
|
case 'bbcode_void':
|
||||||
|
return self.void_tag_map[element['name']](element['attr'])
|
||||||
|
case 'link':
|
||||||
|
return f"<a href=\"{element['url']}\">{element['url']}</a>"
|
||||||
|
case 'emote':
|
||||||
|
return self.emote_map[element['name']]
|
||||||
|
case 'rule':
|
||||||
|
return '<hr>'
|
||||||
|
case 'mention':
|
||||||
|
return self.make_mention(element)
|
||||||
|
case _:
|
||||||
|
raise UnknownASTElementError(
|
||||||
|
element_type=element['type'],
|
||||||
|
element=element
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, ast):
|
||||||
|
out = self.wrap_in_paragraphs(ast, context_is_block=True, is_root=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLRenderer(BabycodeRenderer):
|
||||||
|
def __init__(self, fragment=False):
|
||||||
|
super().__init__(TAGS, VOID_TAGS, EMOJI, fragment)
|
||||||
|
|
||||||
|
self.mentions = []
|
||||||
|
|
||||||
|
def make_mention(self, e):
|
||||||
|
from ..models import Users
|
||||||
|
from flask import url_for, current_app
|
||||||
|
with current_app.test_request_context('/'):
|
||||||
|
target_user = Users.find({'username': e['name'].lower()})
|
||||||
|
if not target_user:
|
||||||
|
return f"@{e['name']}"
|
||||||
|
|
||||||
|
mention_data = {
|
||||||
|
'mention_text': f"@{e['name']}",
|
||||||
|
'mentioned_user_id': int(target_user.id),
|
||||||
|
"start": e['start'],
|
||||||
|
"end": e['end'],
|
||||||
|
}
|
||||||
|
if mention_data not in self.mentions:
|
||||||
|
self.mentions.append(mention_data)
|
||||||
|
|
||||||
|
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||||
|
|
||||||
|
def render(self, ast):
|
||||||
|
out = super().render(ast)
|
||||||
|
return BabycodeRenderResult(out, self.mentions)
|
||||||
|
|
||||||
|
|
||||||
|
class RSSXMLRenderer(BabycodeRenderer):
|
||||||
|
def __init__(self, fragment=False):
|
||||||
|
super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment)
|
||||||
|
|
||||||
|
def make_mention(self, e):
|
||||||
|
from ..models import Users
|
||||||
|
from flask import url_for
|
||||||
|
target_user = Users.find({'username': e['name'].lower()})
|
||||||
|
if not target_user:
|
||||||
|
return f"@{e['name']}"
|
||||||
|
|
||||||
|
return f'<a href="{url_for('users.user_page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
||||||
|
|
||||||
|
|
||||||
|
NAMED_COLORS = [
|
||||||
|
'black', 'silver', 'gray', 'white', 'maroon', 'red',
|
||||||
|
'purple', 'fuchsia', 'green', 'lime', 'olive', 'yellow',
|
||||||
|
'navy', 'blue', 'teal', 'aqua', 'aliceblue', 'antiquewhite',
|
||||||
|
'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
|
||||||
|
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue',
|
||||||
|
'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson',
|
||||||
|
'cyan', 'aqua', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
|
||||||
|
'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange',
|
||||||
|
'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray',
|
||||||
|
'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
|
||||||
|
'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
|
||||||
|
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
|
||||||
|
'greenyellow', 'grey', 'gray', 'honeydew', 'hotpink', 'indianred',
|
||||||
|
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
|
||||||
|
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray',
|
||||||
|
'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
|
||||||
|
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen',
|
||||||
|
'linen', 'magenta', 'fuchsia', 'maroon', 'mediumaquamarine', 'mediumblue',
|
||||||
|
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
|
||||||
|
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite',
|
||||||
|
'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered',
|
||||||
|
'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip',
|
||||||
|
'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple',
|
||||||
|
'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
|
||||||
|
'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
|
||||||
|
'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue',
|
||||||
|
'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise',
|
||||||
|
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_emoji(name, code):
|
||||||
|
return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
|
||||||
|
|
||||||
|
|
||||||
|
EMOJI = {
|
||||||
|
'angry': make_emoji('angry', 'angry'),
|
||||||
|
|
||||||
|
'(': make_emoji('frown', '('),
|
||||||
|
|
||||||
|
'D': make_emoji('grin', 'D'),
|
||||||
|
|
||||||
|
'imp': make_emoji('imp', 'imp'),
|
||||||
|
|
||||||
|
'angryimp': make_emoji('impangry', 'angryimp'),
|
||||||
|
'impangry': make_emoji('impangry', 'impangry'),
|
||||||
|
|
||||||
|
'lobster': make_emoji('lobster', 'lobster'),
|
||||||
|
|
||||||
|
'|': make_emoji('neutral', '|'),
|
||||||
|
|
||||||
|
'pensive': make_emoji('pensive', 'pensive'),
|
||||||
|
|
||||||
|
'scissors': make_emoji('scissors', 'scissors'),
|
||||||
|
|
||||||
|
')': make_emoji('smile', ')'),
|
||||||
|
|
||||||
|
'smiletear': make_emoji('smiletear', 'smiletear'),
|
||||||
|
'crytear': make_emoji('smiletear', 'crytear'),
|
||||||
|
|
||||||
|
',': make_emoji('sob', ','),
|
||||||
|
'T': make_emoji('sob', 'T'),
|
||||||
|
'cry': make_emoji('sob', 'cry'),
|
||||||
|
'sob': make_emoji('sob', 'sob'),
|
||||||
|
|
||||||
|
'o': make_emoji('surprised', 'o'),
|
||||||
|
'O': make_emoji('surprised', 'O'),
|
||||||
|
|
||||||
|
'hmm': make_emoji('think', 'hmm'),
|
||||||
|
'think': make_emoji('think', 'think'),
|
||||||
|
'thinking': make_emoji('think', 'thinking'),
|
||||||
|
|
||||||
|
'P': make_emoji('tongue', 'P'),
|
||||||
|
'p': make_emoji('tongue', 'p'),
|
||||||
|
|
||||||
|
'weary': make_emoji('weary', 'weary'),
|
||||||
|
|
||||||
|
';': make_emoji('wink', ';'),
|
||||||
|
'wink': make_emoji('wink', 'wink'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RSS_EMOJI = {
|
||||||
|
**EMOJI,
|
||||||
|
|
||||||
|
'angry': '😡',
|
||||||
|
|
||||||
|
'(': '🙁',
|
||||||
|
|
||||||
|
'D': '😃',
|
||||||
|
|
||||||
|
'imp': '😈',
|
||||||
|
|
||||||
|
'angryimp': '👿',
|
||||||
|
'impangry': '👿',
|
||||||
|
|
||||||
|
'lobster': '🦞',
|
||||||
|
|
||||||
|
'|': '😐',
|
||||||
|
|
||||||
|
'pensive': '😔',
|
||||||
|
|
||||||
|
'scissors': '✂️',
|
||||||
|
|
||||||
|
')': '🙂',
|
||||||
|
|
||||||
|
'smiletear': '🥲',
|
||||||
|
'crytear': '🥲',
|
||||||
|
|
||||||
|
',': '😭',
|
||||||
|
'T': '😭',
|
||||||
|
'cry': '😭',
|
||||||
|
'sob': '😭',
|
||||||
|
|
||||||
|
'o': '😮',
|
||||||
|
'O': '😮',
|
||||||
|
|
||||||
|
'hmm': '🤔',
|
||||||
|
'think': '🤔',
|
||||||
|
'thinking': '🤔',
|
||||||
|
|
||||||
|
'P': '😛',
|
||||||
|
'p': '😛',
|
||||||
|
|
||||||
|
'weary': '😩',
|
||||||
|
|
||||||
|
';': '😉',
|
||||||
|
'wink': '😉',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_ONLY = ["code"]
|
||||||
|
|
||||||
|
|
||||||
|
def tag_code(children, attr):
|
||||||
|
is_inline = children.find('\n') == -1
|
||||||
|
if is_inline:
|
||||||
|
return f"<code class=\"inline-code\">{children}</code>"
|
||||||
|
else:
|
||||||
|
input_code = children.strip()
|
||||||
|
language = 'code block'
|
||||||
|
if attr:
|
||||||
|
try:
|
||||||
|
lexer = get_lexer_by_name(attr.strip())
|
||||||
|
formatter = HtmlFormatter(nowrap=True)
|
||||||
|
language = lexer.name
|
||||||
|
code = highlight(Markup(input_code).unescape(), lexer, formatter)
|
||||||
|
except PygmentsClassNotFound:
|
||||||
|
code = input_code
|
||||||
|
else:
|
||||||
|
code = input_code
|
||||||
|
|
||||||
|
button = f'<button type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||||
|
block = f'<fieldset data-r="copyCode" value="{input_code}" class="code-block-container plank minimal no-shadow secondary-bg"><legend>{language}</legend>{button}<pre><code>{code}</code></pre></fieldset>'
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def tag_list(children):
|
||||||
|
list_body = re.sub(r" +\n", "<br>", children.strip())
|
||||||
|
list_body = re.sub(r"\n\n+", "\1", list_body)
|
||||||
|
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
|
||||||
|
|
||||||
|
|
||||||
|
def tag_color(children, attr):
|
||||||
|
if not attr:
|
||||||
|
return f"[color]{children}[/color]"
|
||||||
|
|
||||||
|
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
|
||||||
|
potential_color = attr.lower().strip()
|
||||||
|
|
||||||
|
if potential_color in NAMED_COLORS:
|
||||||
|
return f"<span style='color: {potential_color};'>{children}</span>"
|
||||||
|
|
||||||
|
m = re.match(hex_re, potential_color)
|
||||||
|
if m:
|
||||||
|
return f"<span style='color: #{m.group(1)};'>{children}</span>"
|
||||||
|
|
||||||
|
# return just the way it was if we can't parse it
|
||||||
|
return f"[color={attr}]{children}[/color]"
|
||||||
|
|
||||||
|
|
||||||
|
def tag_spoiler(children, attr):
|
||||||
|
spoiler_name = attr if attr else "Spoiler"
|
||||||
|
content = f"<div class='plank minimal even no-shadow hidden'>{children}</div>"
|
||||||
|
container = f"""<details><summary class='plank secondary-bg no-shadow even'>{spoiler_name}</summary>{content}</details>"""
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
|
def tag_image(children, attr):
|
||||||
|
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def tag_quote(children, attr):
|
||||||
|
if attr:
|
||||||
|
quotee = f'Quoting: {attr.strip()}'
|
||||||
|
else:
|
||||||
|
quotee = 'Quote'
|
||||||
|
|
||||||
|
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
|
||||||
|
|
||||||
|
TAGS = {
|
||||||
|
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||||
|
"i": lambda children, attr: f"<em>{children}</em>",
|
||||||
|
"s": lambda children, attr: f"<del>{children}</del>",
|
||||||
|
"u": lambda children, attr: f"<u>{children}</u>",
|
||||||
|
|
||||||
|
"img": tag_image,
|
||||||
|
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||||
|
"quote": tag_quote,
|
||||||
|
"code": tag_code,
|
||||||
|
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||||
|
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
||||||
|
|
||||||
|
"big": lambda children, attr: f"<span style='font-size: 2rem;'>{children}</span>",
|
||||||
|
"small": lambda children, attr: f"<span style='font-size: 0.75rem;'>{children}</span>",
|
||||||
|
"color": tag_color,
|
||||||
|
|
||||||
|
"center": lambda children, attr: f"<div style='text-align: center;'>{children}</div>",
|
||||||
|
"right": lambda children, attr: f"<div style='text-align: right;'>{children}</div>",
|
||||||
|
|
||||||
|
"spoiler": tag_spoiler,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tag_code_rss(children, attr):
|
||||||
|
is_inline = children.find('\n') == -1
|
||||||
|
if is_inline:
|
||||||
|
return f'<code>{children}</code>'
|
||||||
|
else:
|
||||||
|
return f'<pre><code>{children}</code></pre>'
|
||||||
|
|
||||||
|
|
||||||
|
def tag_url_rss(children, attr):
|
||||||
|
if attr.startswith('/'):
|
||||||
|
from flask import current_app
|
||||||
|
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
|
||||||
|
return f"<a href={uri}>{children}</a>"
|
||||||
|
|
||||||
|
return f"<a href={attr}>{children}</a>"
|
||||||
|
|
||||||
|
|
||||||
|
def tag_image_rss(children, attr):
|
||||||
|
if attr.startswith('/'):
|
||||||
|
from flask import current_app
|
||||||
|
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
|
||||||
|
return f'<img src="{uri}" alt={children} />'
|
||||||
|
|
||||||
|
return f'<img src="{attr}" alt={children} />'
|
||||||
|
|
||||||
|
|
||||||
|
RSS_TAGS = {
|
||||||
|
**TAGS,
|
||||||
|
'img': tag_image_rss,
|
||||||
|
'url': tag_url_rss,
|
||||||
|
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
|
||||||
|
'code': tag_code_rss,
|
||||||
|
|
||||||
|
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
|
||||||
|
'small': lambda children, attr: f'<small>{children}</small>'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
VOID_TAGS = {
|
||||||
|
'lb': lambda attr: '[',
|
||||||
|
'rb': lambda attr: ']',
|
||||||
|
'at': lambda attr: '@',
|
||||||
|
'd': lambda attr: '-',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
INLINE_TAGS = {
|
||||||
|
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd', 'img'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_tag(e, tag=None):
|
||||||
|
if e is None:
|
||||||
|
return False
|
||||||
|
if isinstance(e, str):
|
||||||
|
return False
|
||||||
|
if e['type'] != 'bbcode' and e['type'] != 'bbcode_void':
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tag is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return e['name'] == tag
|
||||||
|
|
||||||
|
|
||||||
|
def is_text(e):
|
||||||
|
return isinstance(e, str)
|
||||||
|
|
||||||
|
|
||||||
|
def is_inline(e):
|
||||||
|
if e is None:
|
||||||
|
return False # i think
|
||||||
|
|
||||||
|
if is_text(e):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if is_tag(e):
|
||||||
|
if is_tag(e, 'code'): # special case, since [code] can be inline OR block
|
||||||
|
return '\n' not in e['children'][0]
|
||||||
|
|
||||||
|
return e['name'] in INLINE_TAGS
|
||||||
|
|
||||||
|
return e['type'] != 'rule'
|
||||||
|
|
||||||
|
|
||||||
|
def should_collapse(text, surrounding):
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not text.strip() and '\n' not in text:
|
||||||
|
return not is_inline(surrounding[0]) and not is_inline(surrounding[1])
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(s):
|
||||||
|
return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
|
||||||
|
|
||||||
|
|
||||||
|
def babycode_ast(s: str, banned_tags=[]):
|
||||||
|
"""
|
||||||
|
transforms a string of babycode into an AST.
|
||||||
|
the AST is a list of strings or dicts.
|
||||||
|
|
||||||
|
a string element is plain unformatted text.
|
||||||
|
|
||||||
|
a dict element is a node that contains at least the key `type`.
|
||||||
|
|
||||||
|
possible types are:
|
||||||
|
- bbcode
|
||||||
|
- bbcode_void
|
||||||
|
- link
|
||||||
|
- emote
|
||||||
|
- rule
|
||||||
|
- mention
|
||||||
|
|
||||||
|
bbcode type elements have a children key that is a list of children of that node. the children are themselves elements (string or dict).
|
||||||
|
"""
|
||||||
|
allowed_tags = set(TAGS.keys())
|
||||||
|
if banned_tags is not None:
|
||||||
|
for tag in banned_tags:
|
||||||
|
allowed_tags.discard(tag)
|
||||||
|
subj = sanitize(s)
|
||||||
|
parser = Parser(subj)
|
||||||
|
parser.valid_bbcode_tags = allowed_tags
|
||||||
|
parser.void_bbcode_tags = set(VOID_TAGS)
|
||||||
|
parser.bbcode_tags_only_text_children = TEXT_ONLY
|
||||||
|
parser.mentions_allowed = '@mention' not in banned_tags
|
||||||
|
parser.valid_emotes = EMOJI.keys()
|
||||||
|
|
||||||
|
uncollapsed = parser.parse()
|
||||||
|
elements = []
|
||||||
|
for i in range(len(uncollapsed)):
|
||||||
|
e = uncollapsed[i]
|
||||||
|
surrounding = (
|
||||||
|
uncollapsed[i - 1] if i-1 >= 0 else None,
|
||||||
|
uncollapsed[i + 1] if i+1 < len(uncollapsed) else None
|
||||||
|
)
|
||||||
|
if not should_collapse(e, surrounding):
|
||||||
|
elements.append(e)
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
|
||||||
|
"""
|
||||||
|
transforms a string of babycode into html.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
|
||||||
|
s (str) - babycode string
|
||||||
|
|
||||||
|
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
|
||||||
|
|
||||||
|
fragment (bool) - skip adding an html p tag to the first element if it is inline.
|
||||||
|
"""
|
||||||
|
ast = babycode_ast(s, banned_tags)
|
||||||
|
r = HTMLRenderer(fragment=fragment)
|
||||||
|
return r.render(ast)
|
||||||
|
|
||||||
|
|
||||||
|
def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str:
|
||||||
|
"""
|
||||||
|
transforms a string of babycode into rss-compatible x/html.
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
|
||||||
|
s (str) - babycode string
|
||||||
|
|
||||||
|
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
|
||||||
|
|
||||||
|
fragment (bool) - skip adding an html p tag to the first element if it is inline.
|
||||||
|
"""
|
||||||
|
ast = babycode_ast(s, banned_tags)
|
||||||
|
r = RSSXMLRenderer(fragment=fragment)
|
||||||
|
return r.render(ast)
|
||||||
300
app/lib/babycode_parser.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# originally written in lua by kaesa
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
PAT_EMOTE = r"[^\s:]"
|
||||||
|
PAT_BBCODE_TAG = r"\w"
|
||||||
|
PAT_BBCODE_ATTR = r"[^\]]"
|
||||||
|
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
|
||||||
|
PAT_MENTION = r'[a-zA-Z0-9_-]'
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
def __init__(self, src_str):
|
||||||
|
self.valid_bbcode_tags = {}
|
||||||
|
self.void_bbcode_tags = {}
|
||||||
|
self.valid_emotes = []
|
||||||
|
self.bbcode_tags_only_text_children = []
|
||||||
|
self.mentions_allowed = True
|
||||||
|
self.source = src_str
|
||||||
|
self.position = 0
|
||||||
|
self.position_stack = []
|
||||||
|
|
||||||
|
|
||||||
|
def advance(self, count = 1):
|
||||||
|
self.position += count
|
||||||
|
|
||||||
|
|
||||||
|
def is_end_of_source(self, offset = 0):
|
||||||
|
return self.position + offset >= len(self.source)
|
||||||
|
|
||||||
|
|
||||||
|
def save_position(self):
|
||||||
|
self.position_stack.append(self.position)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_position(self):
|
||||||
|
self.position = self.position_stack.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def forget_position(self):
|
||||||
|
self.position_stack.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def peek_char(self, offset = 0):
|
||||||
|
if self.is_end_of_source(offset):
|
||||||
|
return ""
|
||||||
|
return self.source[self.position + offset]
|
||||||
|
|
||||||
|
|
||||||
|
def get_char(self):
|
||||||
|
char = self.peek_char()
|
||||||
|
self.advance()
|
||||||
|
return char
|
||||||
|
|
||||||
|
|
||||||
|
def check_char(self, wanted):
|
||||||
|
char = self.peek_char()
|
||||||
|
|
||||||
|
if char == wanted:
|
||||||
|
self.advance()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_str(self, wanted):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
# for each char in wanted
|
||||||
|
for i in range(len(wanted)):
|
||||||
|
if not self.check_char(wanted[i]):
|
||||||
|
self.restore_position()
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def match_pattern(self, pattern):
|
||||||
|
buf = ""
|
||||||
|
while not self.is_end_of_source():
|
||||||
|
ch = self.peek_char()
|
||||||
|
|
||||||
|
if not re.match(pattern, ch):
|
||||||
|
break
|
||||||
|
|
||||||
|
self.advance()
|
||||||
|
buf = buf + ch
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def parse_emote(self):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
if not self.check_char(":"):
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = self.match_pattern(PAT_EMOTE)
|
||||||
|
|
||||||
|
if not self.check_char(":"):
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not name in self.valid_emotes:
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return {
|
||||||
|
"type": "emote",
|
||||||
|
"name": name
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bbcode_open(self):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
if not self.check_char("["):
|
||||||
|
self.restore_position()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
name = self.match_pattern(PAT_BBCODE_TAG)
|
||||||
|
|
||||||
|
if name == "":
|
||||||
|
self.restore_position()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
attr = None
|
||||||
|
|
||||||
|
if self.check_char("="):
|
||||||
|
attr = self.match_pattern(PAT_BBCODE_ATTR)
|
||||||
|
|
||||||
|
if not self.check_char("]"):
|
||||||
|
self.restore_position()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if not name in self.valid_bbcode_tags:
|
||||||
|
self.restore_position()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return name, attr
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bbcode(self):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
name, attr = self.parse_bbcode_open()
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
children = []
|
||||||
|
|
||||||
|
while not self.is_end_of_source():
|
||||||
|
if self.check_str(f"[/{name}]"):
|
||||||
|
break
|
||||||
|
|
||||||
|
if name in self.bbcode_tags_only_text_children:
|
||||||
|
ch = self.get_char()
|
||||||
|
|
||||||
|
if len(children) == 0:
|
||||||
|
children.append(ch)
|
||||||
|
else:
|
||||||
|
children[0] = children[0] + ch
|
||||||
|
else:
|
||||||
|
element = self.parse_element(children)
|
||||||
|
|
||||||
|
if element is None:
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
children.append(element)
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return {
|
||||||
|
"type": "bbcode",
|
||||||
|
"name": name,
|
||||||
|
"attr": attr,
|
||||||
|
"children": children,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rule(self):
|
||||||
|
if not self.check_str("---"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "rule"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_link(self):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
# extract printable chars (extreme hack edition)
|
||||||
|
word = self.match_pattern(r'[!-~]')
|
||||||
|
|
||||||
|
match = re.match(PAT_LINK, word)
|
||||||
|
if not match:
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return {
|
||||||
|
"type": "link",
|
||||||
|
"url": word
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_mention(self):
|
||||||
|
if not self.mentions_allowed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
if not self.check_char('@'):
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
mention = self.match_pattern(PAT_MENTION)
|
||||||
|
self.forget_position()
|
||||||
|
return {
|
||||||
|
"type": "mention",
|
||||||
|
"name": mention,
|
||||||
|
"start": self.position - len(mention) - 1,
|
||||||
|
"end": self.position,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bbcode_void(self):
|
||||||
|
self.save_position()
|
||||||
|
|
||||||
|
if not self.check_char("["):
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = self.match_pattern(PAT_BBCODE_TAG)
|
||||||
|
|
||||||
|
if name == "":
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
attr = None
|
||||||
|
|
||||||
|
if self.check_char("="):
|
||||||
|
attr = self.match_pattern(PAT_BBCODE_ATTR)
|
||||||
|
|
||||||
|
if not self.check_char("]"):
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not name in self.void_bbcode_tags:
|
||||||
|
self.restore_position()
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.forget_position()
|
||||||
|
return {
|
||||||
|
'type': 'bbcode_void',
|
||||||
|
'name': name,
|
||||||
|
'attr': attr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_element(self, siblings):
|
||||||
|
if self.is_end_of_source():
|
||||||
|
return None
|
||||||
|
|
||||||
|
element = self.parse_emote() \
|
||||||
|
or self.parse_bbcode_void() \
|
||||||
|
or self.parse_bbcode() \
|
||||||
|
or self.parse_rule() \
|
||||||
|
or self.parse_link() \
|
||||||
|
or self.parse_mention()
|
||||||
|
|
||||||
|
if element is None:
|
||||||
|
if len(siblings) > 0:
|
||||||
|
last = siblings[-1]
|
||||||
|
|
||||||
|
if isinstance(last, str):
|
||||||
|
siblings.pop()
|
||||||
|
return last + self.get_char()
|
||||||
|
|
||||||
|
return self.get_char()
|
||||||
|
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
def parse(self):
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
element = self.parse_element(elements)
|
||||||
|
if element is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
elements.append(element)
|
||||||
|
|
||||||
|
return elements
|
||||||
9
app/lib/exceptions.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class MissingConfigurationException(Exception):
|
||||||
|
def __init__(self, configuration_field: str):
|
||||||
|
message = f"Missing configuration field '{configuration_field}'"
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteNameMissingException(MissingConfigurationException):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('SITE_NAME')
|
||||||
10
app/lib/render_atom.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from flask import make_response, render_template, request
|
||||||
|
|
||||||
|
def render_atom_template(template, *args, **kwargs):
|
||||||
|
injects = {
|
||||||
|
**kwargs,
|
||||||
|
'__current_page': request.url,
|
||||||
|
}
|
||||||
|
r = make_response(render_template(template, *args, **injects))
|
||||||
|
r.mimetype = 'application/xml'
|
||||||
|
return r
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
from .db import db
|
from .db import db
|
||||||
|
|
||||||
# format: {integer: str|list<str>}
|
def migrate_old_avatars():
|
||||||
MIGRATIONS = {
|
for avatar in db.query('SELECT id, file_path FROM avatars WHERE file_path LIKE "/avatars/%"'):
|
||||||
|
new_path = f"/static{avatar['file_path']}"
|
||||||
|
db.execute('UPDATE avatars SET file_path = ? WHERE id = ?', new_path, avatar['id'])
|
||||||
|
|
||||||
}
|
def add_signature_format():
|
||||||
|
db.execute('ALTER TABLE "users" ADD COLUMN "signature_markup_language" TEXT NOT NULL DEFAULT "babycode"')
|
||||||
|
db.execute('ALTER TABLE "users" ADD COLUMN "signature_format_version" INTEGER DEFAULT NULL')
|
||||||
|
|
||||||
|
def create_default_bookmark_collections():
|
||||||
|
from .constants import PermissionLevel
|
||||||
|
q = """SELECT users.id FROM users
|
||||||
|
LEFT JOIN bookmark_collections bc ON (users.id = bc.user_id AND bc.is_default = TRUE)
|
||||||
|
WHERE bc.id IS NULL and users.permission IS NOT ?"""
|
||||||
|
user_ids_without_default_collection = db.query(q, PermissionLevel.SYSTEM.value)
|
||||||
|
if len(user_ids_without_default_collection) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .models import BookmarkCollections
|
||||||
|
for user in user_ids_without_default_collection:
|
||||||
|
BookmarkCollections.create_default(user['id'])
|
||||||
|
|
||||||
|
def add_display_name():
|
||||||
|
dq = 'ALTER TABLE "users" ADD COLUMN "display_name" TEXT NOT NULL DEFAULT ""'
|
||||||
|
db.execute(dq)
|
||||||
|
from .models import Users
|
||||||
|
for user in Users.select():
|
||||||
|
data = {
|
||||||
|
'username': user.username.lower(),
|
||||||
|
}
|
||||||
|
if user.username.lower() != user.username:
|
||||||
|
data['display_name'] = user.username
|
||||||
|
user.update(data)
|
||||||
|
|
||||||
|
# format: [str|tuple(str, any...)|callable]
|
||||||
|
MIGRATIONS = [
|
||||||
|
migrate_old_avatars,
|
||||||
|
'DELETE FROM sessions', # delete old lua porom sessions
|
||||||
|
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
|
||||||
|
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
|
||||||
|
add_signature_format,
|
||||||
|
create_default_bookmark_collections,
|
||||||
|
add_display_name,
|
||||||
|
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL'
|
||||||
|
]
|
||||||
|
|
||||||
def run_migrations():
|
def run_migrations():
|
||||||
db.execute("""
|
db.execute("""
|
||||||
@@ -12,22 +53,25 @@ def run_migrations():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
if len(MIGRATIONS) == 0:
|
if len(MIGRATIONS) == 0:
|
||||||
print("No migrations defined.")
|
print('No migrations defined.')
|
||||||
return
|
return
|
||||||
print("Running migrations...")
|
print('Running migrations...')
|
||||||
ran = 0
|
ran = 0
|
||||||
completed = [row["id"] for row in db.query("SELECT id FROM _migrations")]
|
completed = {int(row['id']) for row in db.query('SELECT id FROM _migrations')}
|
||||||
for migration_id in sorted(MIGRATIONS.keys()):
|
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
|
||||||
if migration_id not in completed:
|
if not to_run:
|
||||||
print(f"Running migration #{migration_id}")
|
print('No migrations need to run.')
|
||||||
|
return
|
||||||
|
|
||||||
|
with db.transaction():
|
||||||
|
for migration_id, migration_obj in to_run.items():
|
||||||
|
if isinstance(migration_obj, str):
|
||||||
|
db.execute(migration_obj)
|
||||||
|
elif isinstance(migration_obj, tuple):
|
||||||
|
db.execute(migration_obj[0], *migration_obj[1:])
|
||||||
|
elif callable(migration_obj):
|
||||||
|
migration_obj()
|
||||||
|
|
||||||
|
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
||||||
ran += 1
|
ran += 1
|
||||||
statements = MIGRATIONS[migration_id]
|
print(f'Ran {ran} migrations.')
|
||||||
# support both strings and lists
|
|
||||||
if isinstance(statements, str):
|
|
||||||
statements = [statements]
|
|
||||||
|
|
||||||
for sql in statements:
|
|
||||||
db.execute(sql)
|
|
||||||
|
|
||||||
db.execute("INSERT INTO _migrations (id) VALUES (?)", migration_id)
|
|
||||||
print(f"Ran {ran} migrations.")
|
|
||||||
|
|||||||
532
app/models.py
@@ -1 +1,531 @@
|
|||||||
from .db import Model
|
from .db import Model, db
|
||||||
|
from .constants import PermissionLevel
|
||||||
|
from flask import current_app
|
||||||
|
import time
|
||||||
|
|
||||||
|
class Users(Model):
|
||||||
|
table = 'users'
|
||||||
|
|
||||||
|
def get_avatar_url(self):
|
||||||
|
return Avatars.find({'id': self.avatar_id}).file_path
|
||||||
|
|
||||||
|
def is_default_avatar(self):
|
||||||
|
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
||||||
|
|
||||||
|
def is_guest(self):
|
||||||
|
return self.permission == PermissionLevel.GUEST.value
|
||||||
|
|
||||||
|
def is_mod(self):
|
||||||
|
return self.permission >= PermissionLevel.MODERATOR.value
|
||||||
|
|
||||||
|
def is_mod_only(self):
|
||||||
|
return self.permission == PermissionLevel.MODERATOR.value
|
||||||
|
|
||||||
|
def is_admin(self):
|
||||||
|
return self.permission == PermissionLevel.ADMIN.value
|
||||||
|
|
||||||
|
def is_system(self):
|
||||||
|
return self.permission == PermissionLevel.SYSTEM.value
|
||||||
|
|
||||||
|
def is_default_avatar(self):
|
||||||
|
return self.avatar_id == 1
|
||||||
|
|
||||||
|
def get_post_stats(self):
|
||||||
|
q = """SELECT
|
||||||
|
COUNT(DISTINCT 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,
|
||||||
|
inviter.username AS inviter_username,
|
||||||
|
inviter.display_name AS inviter_display_name
|
||||||
|
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
|
||||||
|
LEFT JOIN users AS inviter ON inviter.id = users.invited_by
|
||||||
|
WHERE users.id = ?"""
|
||||||
|
return db.fetch_one(q, self.id)
|
||||||
|
|
||||||
|
def get_all_subscriptions(self):
|
||||||
|
q = """
|
||||||
|
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 = ?"""
|
||||||
|
return db.query(q, self.id)
|
||||||
|
|
||||||
|
def can_post_to_topic(self, topic):
|
||||||
|
if self.is_guest():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.is_mod():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if topic['is_locked']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_invite(self):
|
||||||
|
if not current_app.config['DISABLE_SIGNUP']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if current_app.config['MODS_CAN_INVITE'] and self.is_mod():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if current_app.config['USERS_CAN_INVITE'] and not self.is_guest():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_bookmark_collections(self):
|
||||||
|
q = 'SELECT id FROM bookmark_collections WHERE user_id = ? ORDER BY sort_order ASC'
|
||||||
|
res = db.query(q, self.id)
|
||||||
|
return [BookmarkCollections.find({'id': bc['id']}) for bc in res]
|
||||||
|
|
||||||
|
def get_readable_name(self):
|
||||||
|
if self.display_name:
|
||||||
|
return self.display_name
|
||||||
|
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def has_display_name(self):
|
||||||
|
return self.display_name != ''
|
||||||
|
|
||||||
|
def get_badges(self):
|
||||||
|
return Badges.findall({'user_id': int(self.id)})
|
||||||
|
|
||||||
|
|
||||||
|
class Topics(Model):
|
||||||
|
table = 'topics'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_list(_cls):
|
||||||
|
q = """
|
||||||
|
SELECT
|
||||||
|
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||||
|
COUNT(DISTINCT threads.id) as threads_count,
|
||||||
|
COUNT(posts.id) AS posts_count,
|
||||||
|
MAX(posts.created_at) as latest_post_timestamp
|
||||||
|
FROM
|
||||||
|
topics
|
||||||
|
LEFT JOIN
|
||||||
|
threads ON threads.topic_id = topics.id
|
||||||
|
LEFT JOIN
|
||||||
|
posts ON posts.thread_id = threads.id
|
||||||
|
GROUP BY topics.id ORDER BY topics.sort_order ASC"""
|
||||||
|
return db.query(q)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(_cls, name: str, description: str) -> Topics:
|
||||||
|
from slugify import slugify
|
||||||
|
name = name.strip()
|
||||||
|
description = description.strip()
|
||||||
|
now = int(time.time())
|
||||||
|
slug = f'{slugify(name)}-{now}'
|
||||||
|
|
||||||
|
topic_count = Topics.count()
|
||||||
|
return Topics.create({
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'slug': slug,
|
||||||
|
'sort_order': topic_count + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_threads(self, per_page, page, sort_by = 'activity'):
|
||||||
|
order_clause = ''
|
||||||
|
if sort_by == 'thread':
|
||||||
|
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'
|
||||||
|
|
||||||
|
q = """
|
||||||
|
WITH latest_posts AS (
|
||||||
|
SELECT
|
||||||
|
thread_id,
|
||||||
|
id AS latest_post_id,
|
||||||
|
user_id AS latest_post_user_id,
|
||||||
|
created_at AS latest_post_created_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
|
||||||
|
FROM posts
|
||||||
|
),
|
||||||
|
post_counts AS (
|
||||||
|
SELECT
|
||||||
|
thread_id,
|
||||||
|
COUNT(*) AS posts_count
|
||||||
|
FROM posts
|
||||||
|
GROUP BY thread_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
threads.id,
|
||||||
|
threads.title,
|
||||||
|
threads.slug,
|
||||||
|
threads.created_at,
|
||||||
|
threads.is_locked,
|
||||||
|
threads.is_stickied,
|
||||||
|
starter.username AS started_by,
|
||||||
|
starter.display_name AS started_by_display_name,
|
||||||
|
latest_poster.username AS latest_post_username,
|
||||||
|
latest_poster.display_name AS latest_post_display_name,
|
||||||
|
latest_posts.latest_post_created_at,
|
||||||
|
latest_posts.latest_post_id,
|
||||||
|
COALESCE(post_counts.posts_count, 0) AS posts_count
|
||||||
|
FROM threads
|
||||||
|
JOIN users AS starter ON starter.id = threads.user_id
|
||||||
|
LEFT JOIN latest_posts ON latest_posts.thread_id = threads.id AND latest_posts.rn = 1
|
||||||
|
LEFT JOIN users AS latest_poster ON latest_poster.id = latest_posts.latest_post_user_id
|
||||||
|
LEFT JOIN post_counts ON post_counts.thread_id = threads.id
|
||||||
|
WHERE threads.topic_id = ?
|
||||||
|
""" + order_clause + ' LIMIT ? OFFSET ?'
|
||||||
|
|
||||||
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
|
def get_threads_with_op_rss(self):
|
||||||
|
q = """
|
||||||
|
SELECT
|
||||||
|
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
||||||
|
users.username AS started_by,
|
||||||
|
users.display_name AS started_by_display_name,
|
||||||
|
ph.content_rss AS original_post_content,
|
||||||
|
posts.id AS original_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 ASC) 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 BY threads.created_at DESC"""
|
||||||
|
|
||||||
|
return db.query(q, self.id)
|
||||||
|
|
||||||
|
def locked(self):
|
||||||
|
return bool(self.is_locked)
|
||||||
|
|
||||||
|
|
||||||
|
class Threads(Model):
|
||||||
|
table = 'threads'
|
||||||
|
|
||||||
|
def get_posts(self, per_page, page):
|
||||||
|
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?'
|
||||||
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
|
def get_posts_rss(self):
|
||||||
|
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
|
||||||
|
return db.query(q, self.id)
|
||||||
|
|
||||||
|
def locked(self):
|
||||||
|
return bool(self.is_locked)
|
||||||
|
|
||||||
|
def stickied(self):
|
||||||
|
return bool(self.is_stickied)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, user_id: int, topic_id: int, title: str, content: str, language: str = 'babycode') -> Threads:
|
||||||
|
from slugify import slugify
|
||||||
|
now = int(time.time())
|
||||||
|
slug = f'{slugify(title)}-{now}'
|
||||||
|
thread = Threads.create({
|
||||||
|
'topic_id': topic_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'title': title.strip(),
|
||||||
|
'slug': slug,
|
||||||
|
'created_at': int(time.time()),
|
||||||
|
})
|
||||||
|
post = Posts.new(user_id, thread.id, content, language)
|
||||||
|
return thread
|
||||||
|
|
||||||
|
class Posts(Model):
|
||||||
|
FULL_POSTS_QUERY = """
|
||||||
|
WITH user_badges AS (
|
||||||
|
SELECT
|
||||||
|
b.user_id,
|
||||||
|
json_group_array(
|
||||||
|
json_object(
|
||||||
|
'label', b.label,
|
||||||
|
'link', b.link,
|
||||||
|
'sort_order', b.sort_order,
|
||||||
|
'file_path', bu.file_path
|
||||||
|
)
|
||||||
|
) AS badges_json
|
||||||
|
FROM badges b
|
||||||
|
LEFT JOIN badge_uploads bu ON b.upload = bu.id
|
||||||
|
GROUP BY b.user_id
|
||||||
|
ORDER BY b.sort_order
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
posts.id, posts.created_at,
|
||||||
|
post_history.content, post_history.edited_at, post_history.content_rss,
|
||||||
|
users.username, users.display_name, users.status,
|
||||||
|
avatars.file_path AS avatar_path, posts.thread_id,
|
||||||
|
users.id AS user_id, post_history.original_markup,
|
||||||
|
users.signature_rendered, threads.slug AS thread_slug,
|
||||||
|
threads.is_locked AS thread_is_locked, threads.title AS thread_title,
|
||||||
|
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
JOIN
|
||||||
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
|
JOIN
|
||||||
|
users ON posts.user_id = users.id
|
||||||
|
JOIN
|
||||||
|
threads ON posts.thread_id = threads.id
|
||||||
|
LEFT JOIN
|
||||||
|
avatars ON users.avatar_id = avatars.id
|
||||||
|
LEFT JOIN
|
||||||
|
user_badges ON users.id = user_badges.user_id"""
|
||||||
|
|
||||||
|
table = 'posts'
|
||||||
|
|
||||||
|
def get_full_post_view(self):
|
||||||
|
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
||||||
|
return db.fetch_one(q, self.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, user_id: int, thread_id: int, content: str, language: str = 'babycode') -> Posts:
|
||||||
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
|
||||||
|
html_content = babycode_to_html(content)
|
||||||
|
rssxml_content = babycode_to_rssxml(content)
|
||||||
|
with db.transaction():
|
||||||
|
post = Posts.create({
|
||||||
|
'thread_id': thread_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'current_revision_id': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
revision = PostHistory.create({
|
||||||
|
'post_id': post.id,
|
||||||
|
'content': html_content.result,
|
||||||
|
'content_rss': rssxml_content,
|
||||||
|
'is_initial_revision': True,
|
||||||
|
'original_markup': content,
|
||||||
|
'markup_language': language,
|
||||||
|
'format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
for mention in html_content.mentions:
|
||||||
|
Mentions.create({
|
||||||
|
'revision_id': revision.id,
|
||||||
|
'mentioned_iser_id': mention['mentioned_iser_id'],
|
||||||
|
'start_index': mention['start'],
|
||||||
|
'end_index': mention['end'],
|
||||||
|
})
|
||||||
|
|
||||||
|
post.update({'current_revision_id': revision.id})
|
||||||
|
return post
|
||||||
|
|
||||||
|
class PostHistory(Model):
|
||||||
|
table = 'post_history'
|
||||||
|
|
||||||
|
class Sessions(Model):
|
||||||
|
table = 'sessions'
|
||||||
|
|
||||||
|
class Avatars(Model):
|
||||||
|
table = 'avatars'
|
||||||
|
|
||||||
|
class Subscriptions(Model):
|
||||||
|
table = 'subscriptions'
|
||||||
|
|
||||||
|
def get_unread_count(self):
|
||||||
|
q = """SELECT COUNT(*) AS unread_count
|
||||||
|
FROM posts
|
||||||
|
LEFT JOIN subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||||
|
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen AND posts.thread_id = ?"""
|
||||||
|
res = db.fetch_one(q, self.user_id, self.thread_id)
|
||||||
|
if res:
|
||||||
|
return res['unread_count']
|
||||||
|
return None
|
||||||
|
|
||||||
|
class APIRateLimits(Model):
|
||||||
|
table = 'api_rate_limits'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_allowed(cls, user_id, method, seconds):
|
||||||
|
q = """
|
||||||
|
SELECT logged_at FROM api_rate_limits
|
||||||
|
WHERE user_id = ? AND method = ?
|
||||||
|
ORDER BY logged_at DESC LIMIT 1"""
|
||||||
|
last_call = db.fetch_one(q, user_id, method)
|
||||||
|
if last_call is None or (int(time.time()) - int(last_call['logged_at']) >= seconds):
|
||||||
|
with db.transaction():
|
||||||
|
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
|
||||||
|
|
||||||
|
class Reactions(Model):
|
||||||
|
table = 'reactions'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_post(cls, post_id):
|
||||||
|
qb = db.QueryBuilder(cls.table)\
|
||||||
|
.select('reaction_text, COUNT(*) as c')\
|
||||||
|
.where({'post_id': post_id})\
|
||||||
|
.group_by('reaction_text')\
|
||||||
|
.order_by('c', False)
|
||||||
|
result = qb.all()
|
||||||
|
return result if result else []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_users(cls, post_id, reaction_text):
|
||||||
|
q = """
|
||||||
|
SELECT user_id, username FROM reactions
|
||||||
|
JOIN
|
||||||
|
users ON users.id = user_id
|
||||||
|
WHERE
|
||||||
|
post_id = ? AND reaction_text = ?
|
||||||
|
"""
|
||||||
|
|
||||||
|
return db.query(q, post_id, reaction_text)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetLinks(Model):
|
||||||
|
table = 'password_reset_links'
|
||||||
|
|
||||||
|
|
||||||
|
class InviteKeys(Model):
|
||||||
|
table = 'invite_keys'
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkCollections(Model):
|
||||||
|
table = 'bookmark_collections'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_default(cls, user_id):
|
||||||
|
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
|
||||||
|
VALUES (?, "Bookmarks", 1, 0) RETURNING id
|
||||||
|
"""
|
||||||
|
res = db.fetch_one(q, user_id)
|
||||||
|
|
||||||
|
def has_posts(self):
|
||||||
|
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
|
||||||
|
res = db.fetch_one(q, self.id)['e']
|
||||||
|
return int(res) == 1
|
||||||
|
|
||||||
|
def has_threads(self):
|
||||||
|
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ?) as e'
|
||||||
|
res = db.fetch_one(q, self.id)['e']
|
||||||
|
return int(res) == 1
|
||||||
|
|
||||||
|
def is_empty(self):
|
||||||
|
return not (self.has_posts() or self.has_threads())
|
||||||
|
|
||||||
|
def get_threads(self):
|
||||||
|
q = 'SELECT id FROM bookmarked_threads WHERE collection_id = ?'
|
||||||
|
res = db.query(q, self.id)
|
||||||
|
return [BookmarkedThreads.find({'id': bt['id']}) for bt in res]
|
||||||
|
|
||||||
|
def get_posts(self):
|
||||||
|
q = 'SELECT id FROM bookmarked_posts WHERE collection_id = ?'
|
||||||
|
res = db.query(q, self.id)
|
||||||
|
return [BookmarkedPosts.find({'id': bt['id']}) for bt in res]
|
||||||
|
|
||||||
|
def get_threads_count(self):
|
||||||
|
q = 'SELECT COUNT(*) as tc FROM bookmarked_threads WHERE collection_id = ?'
|
||||||
|
res = db.fetch_one(q, self.id)
|
||||||
|
return int(res['tc'])
|
||||||
|
|
||||||
|
def get_posts_count(self):
|
||||||
|
q = 'SELECT COUNT(*) as pc FROM bookmarked_posts WHERE collection_id = ?'
|
||||||
|
res = db.fetch_one(q, self.id)
|
||||||
|
return int(res['pc'])
|
||||||
|
|
||||||
|
def has_thread(self, thread_id):
|
||||||
|
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ? AND thread_id = ?) as e'
|
||||||
|
res = db.fetch_one(q, self.id, int(thread_id))['e']
|
||||||
|
return int(res) == 1
|
||||||
|
|
||||||
|
def has_post(self, post_id):
|
||||||
|
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ? AND post_id = ?) as e'
|
||||||
|
res = db.fetch_one(q, self.id, int(post_id))['e']
|
||||||
|
return int(res) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkedPosts(Model):
|
||||||
|
table = 'bookmarked_posts'
|
||||||
|
|
||||||
|
def get_post(self):
|
||||||
|
return Posts.find({'id': self.post_id})
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkedThreads(Model):
|
||||||
|
table = 'bookmarked_threads'
|
||||||
|
|
||||||
|
def get_thread(self):
|
||||||
|
return Threads.find({'id': self.thread_id})
|
||||||
|
|
||||||
|
|
||||||
|
class MOTD(Model):
|
||||||
|
table = 'motd'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_motd(cls):
|
||||||
|
q = 'SELECT EXISTS(SELECT 1 FROM motd) as e'
|
||||||
|
res = db.fetch_one(q)['e']
|
||||||
|
return int(res) == 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls):
|
||||||
|
q = 'SELECT id FROM motd'
|
||||||
|
res = db.query(q)
|
||||||
|
return [MOTD.find({'id': i['id']}) for i in res]
|
||||||
|
|
||||||
|
|
||||||
|
class Mentions(Model):
|
||||||
|
table = 'mentions'
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeUploads(Model):
|
||||||
|
table = 'badge_uploads'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default(cls):
|
||||||
|
return BadgeUploads.findall({'user_id': None}, 'IS')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, user_id):
|
||||||
|
q = 'SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at'
|
||||||
|
res = db.query(q, int(user_id))
|
||||||
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unused_for_user(cls, user_id):
|
||||||
|
q = 'SELECT bu.* FROM badge_uploads bu LEFT JOIN badges b ON bu.id = b.upload WHERE bu.user_id = ? AND b.upload IS NULL'
|
||||||
|
res = db.query(q, int(user_id))
|
||||||
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
|
|
||||||
|
class Badges(Model):
|
||||||
|
table = 'badges'
|
||||||
|
|
||||||
|
def get_image_url(self):
|
||||||
|
bu = BadgeUploads.find({'id': int(self.upload)})
|
||||||
|
return bu.file_path
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import Blueprint
|
from flask import Blueprint, redirect, url_for, render_template
|
||||||
|
bp = Blueprint('app', __name__, url_prefix = '/')
|
||||||
|
|
||||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
@bp.route("/")
|
return redirect(url_for('topics.all_topics'))
|
||||||
def hello_world():
|
|
||||||
return f"<img src='static/avatars/default.webp'></img>"
|
|
||||||
|
|||||||
11
app/routes/guides.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('guides', __name__, url_prefix = '/guides/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/contact')
|
||||||
|
def contact():
|
||||||
|
return 'stub'
|
||||||
96
app/routes/mod.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from flask import Blueprint, abort, redirect, url_for, request, render_template
|
||||||
|
from ..auth import is_logged_in, get_active_user, csrf_verified
|
||||||
|
from ..models import Topics, Threads
|
||||||
|
bp = Blueprint('mod', __name__, url_prefix='/mod/')
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
def mod_only():
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(403)
|
||||||
|
if not get_active_user().is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/topics/new/')
|
||||||
|
def new_topic():
|
||||||
|
return render_template('mod/new_topic.html')
|
||||||
|
|
||||||
|
@bp.post('/topics/new/')
|
||||||
|
def new_topic_post():
|
||||||
|
topic = Topics.new(request.form.get('name'), request.form.get('description'))
|
||||||
|
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||||
|
|
||||||
|
@bp.get('/topics/sort/')
|
||||||
|
def sort_topics():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/topics/<int:topic_id>/edit/')
|
||||||
|
def edit_topic(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
return render_template('mod/edit_topic.html', topic=topic)
|
||||||
|
|
||||||
|
@bp.post('/topics/<int:topic_id>/edit/')
|
||||||
|
def edit_topic_post(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
topic.update({
|
||||||
|
'name': request.form.get('name').strip(),
|
||||||
|
'description': request.form.get('description').strip(),
|
||||||
|
})
|
||||||
|
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||||
|
|
||||||
|
@bp.post('/topics/<int:topic_id>/lock/')
|
||||||
|
def lock_topic(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
topic.update({'is_locked': request.form.get('lock', default=0)})
|
||||||
|
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/move/')
|
||||||
|
def move_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
target_topic = Topics.find({'id': request.form.get('new_topic_id', default=None)})
|
||||||
|
if not target_topic:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'topic_id': target_topic.id})
|
||||||
|
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/lock/')
|
||||||
|
def lock_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'is_locked': request.form.get('lock')})
|
||||||
|
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/sticky/')
|
||||||
|
def sticky_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'is_stickied': request.form.get('sticky')})
|
||||||
|
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-guest/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_guest(user_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-user/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_regular(user_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-mod/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_mod(user_id):
|
||||||
|
return 'stub'
|
||||||
44
app/routes/posts.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from flask import Blueprint, abort
|
||||||
|
from functools import wraps
|
||||||
|
from ..auth import login_required, get_active_user
|
||||||
|
from ..models import Posts
|
||||||
|
|
||||||
|
bp = Blueprint('posts', __name__, url_prefix='/posts/')
|
||||||
|
|
||||||
|
def ownership_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||||
|
if not post:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if post.user_id != get_active_user().id:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def ownership_or_mod_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||||
|
if not post:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if post.user_id != get_active_user().id and not get_active_user().is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@bp.get('/<int:post_id>/edit/')
|
||||||
|
@login_required
|
||||||
|
@ownership_required
|
||||||
|
def edit(post_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<int:post_id>/delete/')
|
||||||
|
@login_required
|
||||||
|
@ownership_or_mod_required
|
||||||
|
def delete(post_id):
|
||||||
|
return 'stub'
|
||||||
102
app/routes/threads.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||||
|
from ..auth import login_required, get_active_user
|
||||||
|
from ..models import Threads, Posts, Topics, Users, Reactions
|
||||||
|
import math
|
||||||
|
|
||||||
|
bp = Blueprint('threads', __name__, url_prefix='/threads/')
|
||||||
|
|
||||||
|
@bp.get('/<int:thread_id>/')
|
||||||
|
def thread_by_id(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
|
||||||
|
|
||||||
|
@bp.get('/<int:thread_id>/<slug>/')
|
||||||
|
def thread(thread_id, slug):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
if thread.slug != slug:
|
||||||
|
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.kwargs))
|
||||||
|
|
||||||
|
topic = Topics.find({'id': thread.topic_id})
|
||||||
|
started_by = Users.find({'id': thread.user_id})
|
||||||
|
PER_PAGE = 10
|
||||||
|
posts_count = Posts.count({'thread_id': thread.id})
|
||||||
|
page_count = max(1, math.ceil(posts_count / PER_PAGE))
|
||||||
|
page = 1
|
||||||
|
after = request.args.get('after')
|
||||||
|
if after is not None:
|
||||||
|
try:
|
||||||
|
after_id = int(after)
|
||||||
|
post_position = Posts.count([
|
||||||
|
('thread_id', '=', thread.id),
|
||||||
|
('id', '<=', after_id),
|
||||||
|
])
|
||||||
|
page = math.ceil((post_position) / PER_PAGE)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
return render_template('threads/thread.html', thread=thread, posts=thread.get_posts(PER_PAGE, page), page=page, page_count=page_count, topic=topic, started_by=started_by, topics=Topics.get_list(), Reactions=Reactions)
|
||||||
|
|
||||||
|
@bp.post('/<int:thread_id>/reply/')
|
||||||
|
@login_required
|
||||||
|
def reply(thread_id):
|
||||||
|
user = get_active_user()
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
if thread.locked() and not user.is_mod():
|
||||||
|
# TODO: flash
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||||
|
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
|
||||||
|
|
||||||
|
@bp.get('/<int:thread_id>/feed.atom/')
|
||||||
|
def feed(thread_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/new/')
|
||||||
|
@login_required
|
||||||
|
def new():
|
||||||
|
topics = Topics.select()
|
||||||
|
try:
|
||||||
|
selected_topic = int(request.args.get('topic_id'))
|
||||||
|
except ValueError, TypeError:
|
||||||
|
selected_topic = None
|
||||||
|
return render_template('threads/new_thread.html', topics=topics, selected_topic=selected_topic)
|
||||||
|
|
||||||
|
@bp.post('/new/')
|
||||||
|
@login_required
|
||||||
|
def new_post():
|
||||||
|
try:
|
||||||
|
topic_id = int(request.form.get('topic_id'))
|
||||||
|
except ValueError, TypeError:
|
||||||
|
abort(404)
|
||||||
|
topic_id = int(topic_id)
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if not user.can_post_to_topic(topic):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
title = request.form.get('title')
|
||||||
|
if not title:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not title.strip():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
title = title.strip()
|
||||||
|
|
||||||
|
content = request.form.get('babycode_content')
|
||||||
|
|
||||||
|
thread = Threads.new(user.id, topic.id, title, content)
|
||||||
|
return redirect(url_for('.thread', slug=thread.slug))
|
||||||
40
app/routes/topics.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||||
|
|
||||||
|
from ..models import Topics, Threads
|
||||||
|
import math
|
||||||
|
|
||||||
|
bp = Blueprint('topics', __name__, url_prefix = '/topics/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def all_topics():
|
||||||
|
topic_list = Topics.get_list()
|
||||||
|
return render_template('topics/topics.html', topics=topic_list)
|
||||||
|
|
||||||
|
@bp.get('/<int:topic_id>/')
|
||||||
|
def topic_by_id(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
return redirect(url_for('.topic', topic_id=topic_id, slug=topic.slug, **request.args))
|
||||||
|
|
||||||
|
@bp.get('/<int:topic_id>/<slug>/')
|
||||||
|
def topic(topic_id, slug):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
if topic.slug != slug:
|
||||||
|
return redirect(url_for('.topic', topic_id=topic_id, slug=topic.slug, **request.args))
|
||||||
|
|
||||||
|
sort_by = request.args.get('sort_by', default=session.get('sort_by', default='activity'))
|
||||||
|
PER_PAGE = 10
|
||||||
|
threads_count = Threads.count({'topic_id': topic.id})
|
||||||
|
page_count = max(1, math.ceil(threads_count / PER_PAGE))
|
||||||
|
try:
|
||||||
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
return render_template('topics/topic.html', topic=topic, threads=topic.get_threads(PER_PAGE, page, sort_by), sort_by=sort_by, page=page, page_count=page_count)
|
||||||
|
|
||||||
|
@bp.get('/<int:topic_id>/feed.atom/')
|
||||||
|
def feed(topic_id):
|
||||||
|
return 'stub'
|
||||||
135
app/routes/users.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, session
|
||||||
|
from functools import wraps
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..auth import (
|
||||||
|
digest, verify, create_session,
|
||||||
|
is_logged_in, parse_username, is_password_valid,
|
||||||
|
login_required
|
||||||
|
)
|
||||||
|
from ..models import Users
|
||||||
|
from ..constants import PermissionLevel
|
||||||
|
from secrets import compare_digest as compare_timesafe
|
||||||
|
|
||||||
|
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||||
|
|
||||||
|
def redirect_if_logged_in(destination='topics.all_topics'):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if is_logged_in():
|
||||||
|
return redirect(url_for(destination))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
@bp.get('/log-in/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def log_in():
|
||||||
|
return render_template('users/log_in.html')
|
||||||
|
|
||||||
|
@bp.post('/log-out/')
|
||||||
|
@login_required
|
||||||
|
def log_out():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/log-in/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def log_in_post():
|
||||||
|
username = request.form.get('username', default='').lower()
|
||||||
|
user = Users.find({'username': username})
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||||
|
password = request.form.get('password', default='')
|
||||||
|
if not verify(user.password_hash, password):
|
||||||
|
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||||
|
|
||||||
|
session['remember'] = request.form.get('remember') == 'on'
|
||||||
|
sess = create_session(user.id, not session['remember'])
|
||||||
|
session['pyrom_session_key'] = sess.key
|
||||||
|
if session['remember']:
|
||||||
|
session.permanent = True
|
||||||
|
return redirect(request.form.get('return_to', default=url_for('topics.all_topics')))
|
||||||
|
|
||||||
|
@bp.get('/sign-up/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def sign_up():
|
||||||
|
return render_template('users/sign_up.html')
|
||||||
|
|
||||||
|
@bp.post('/sign-up/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def sign_up_post():
|
||||||
|
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.'))
|
||||||
|
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.'))
|
||||||
|
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.'))
|
||||||
|
username = request.form.get('username', default='')
|
||||||
|
if not username:
|
||||||
|
return generic_error_page
|
||||||
|
if request.form.get('password') is None:
|
||||||
|
return generic_error_page
|
||||||
|
if len(request.form.getlist('password')) != 2:
|
||||||
|
return passwords_error_page
|
||||||
|
try:
|
||||||
|
username_pair = parse_username(username)
|
||||||
|
except ValueError:
|
||||||
|
return invalid_username_error_page
|
||||||
|
potential_user = Users.find({'username': username})
|
||||||
|
if potential_user:
|
||||||
|
return invalid_username_error_page
|
||||||
|
|
||||||
|
if not compare_timesafe(request.form.getlist('password')[0], request.form.getlist('password')[1]):
|
||||||
|
return passwords_error_page
|
||||||
|
|
||||||
|
password_hash = digest(request.form.get('password'))
|
||||||
|
|
||||||
|
user = Users.create({
|
||||||
|
'username': username_pair[0],
|
||||||
|
'password_hash': password_hash,
|
||||||
|
'permission': PermissionLevel.GUEST.value,
|
||||||
|
'created_at': int(time.time()),
|
||||||
|
})
|
||||||
|
|
||||||
|
if username_pair[0] != username_pair[1]:
|
||||||
|
user.update({
|
||||||
|
'display_name': username_pair[1]
|
||||||
|
})
|
||||||
|
|
||||||
|
session['remember'] = request.form.get('remember') == 'on'
|
||||||
|
sess = create_session(user.id, not session['remember'])
|
||||||
|
session['pyrom_session_key'] = sess.key
|
||||||
|
if session['remember']:
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
|
|
||||||
|
@bp.get('/<username>/')
|
||||||
|
def user_page(username):
|
||||||
|
target_user = Users.find({'username': username})
|
||||||
|
if not target_user:
|
||||||
|
abort(404)
|
||||||
|
return render_template('users/user_page.html', target_user=target_user)
|
||||||
|
|
||||||
|
@bp.get('/<username>/posts/')
|
||||||
|
def posts(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/threads/')
|
||||||
|
def threads(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/comments/')
|
||||||
|
def comments(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/settings/')
|
||||||
|
def settings(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/inbox/')
|
||||||
|
def inbox(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/bookmarks/')
|
||||||
|
def bookmarks(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
124
app/schema.py
@@ -76,22 +76,122 @@ SCHEMA = [
|
|||||||
"signature_rendered" TEXT NOT NULL DEFAULT ''
|
"signature_rendered" TEXT NOT NULL DEFAULT ''
|
||||||
)""",
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "reactions" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
"reaction_text" TEXT NOT NULL DEFAULT ''
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||||
|
"key" TEXT NOT NULL UNIQUE
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "invite_keys" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"created_by" REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"key" TEXT NOT NULL UNIQUE
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "bookmark_collections" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"is_default" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "bookmarked_posts" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
|
||||||
|
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
"note" TEXT,
|
||||||
|
UNIQUE(collection_id, post_id)
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "bookmarked_threads" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
|
||||||
|
"thread_id" REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
"note" TEXT,
|
||||||
|
UNIQUE(collection_id, thread_id)
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "motd" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body_original_markup" TEXT NOT NULL,
|
||||||
|
"body_rendered" TEXT NOT NULL,
|
||||||
|
"markup_language" TEXT NOT NULL DEFAULT 'babycode',
|
||||||
|
"format_version" INTEGER DEFAULT NULL,
|
||||||
|
"created_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||||
|
"edited_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||||
|
"user_id" REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "mentions" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"revision_id" REFERENCES post_history(id) ON DELETE CASCADE,
|
||||||
|
"mentioned_user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"start_index" INTEGER NOT NULL,
|
||||||
|
"end_index" INTEGER NOT NULL,
|
||||||
|
"original_mention_text" TEXT NOT NULL
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "badge_uploads" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"file_path" TEXT NOT NULL UNIQUE,
|
||||||
|
"uploaded_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||||
|
"original_filename" TEXT,
|
||||||
|
"user_id" REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)""",
|
||||||
|
|
||||||
|
"""CREATE TABLE IF NOT EXISTS "badges" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"user_id" NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"upload" NOT NULL REFERENCES badge_uploads(id) ON DELETE CASCADE,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"link" TEXT DEFAULT '',
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0
|
||||||
|
)""",
|
||||||
|
|
||||||
# INDEXES
|
# INDEXES
|
||||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
|
'CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
|
'CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)",
|
'CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)",
|
'CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)",
|
'CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)",
|
'CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)",
|
'CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)",
|
'CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
|
'CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)',
|
||||||
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)",
|
'CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
|
||||||
]
|
]
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
print("Creating schema...")
|
print('Creating schema...')
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
for stmt in SCHEMA:
|
for stmt in SCHEMA:
|
||||||
db.execute(stmt)
|
db.execute(stmt)
|
||||||
print("Schema completed.")
|
print('Schema completed.')
|
||||||
|
|||||||
19
app/templates/base.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
|
<link rel="stylesheet" href="{{ "/static/css/style.css" | cachebust }}">
|
||||||
|
{% if self.title() -%}
|
||||||
|
<title>{{ config.SITE_NAME }} - {% block title -%}{%- endblock -%}</title>
|
||||||
|
{%- else -%}
|
||||||
|
<title>{{ config.SITE_NAME }}</title>
|
||||||
|
{%- endif -%}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{%- include 'common/topnav.html' -%}
|
||||||
|
{%- block content -%}{%- endblock -%}
|
||||||
|
{%- include 'common/footer.html' -%}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
app/templates/common/404.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}Not found{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader('404 Not Found') -%}
|
||||||
|
<span>The requested URL was not found.</span>
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endblock -%}
|
||||||
7
app/templates/common/footer.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<footer class="plank secondary-bg bottom">
|
||||||
|
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li><a href="{{url_for('guides.contact')}}">Contact</a></li>
|
||||||
|
<li><a href="{{url_for('guides.index')}}">Guides</a></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
182
app/templates/common/macros.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{% macro timestamp(unix_ts) -%}
|
||||||
|
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro subheader(title, desc='') -%}
|
||||||
|
<div id="subheader" class="plank secondary-bg">
|
||||||
|
<h1 class="info">{{title}}</h1>
|
||||||
|
{%- if desc -%}<span>{{desc}}</span>{%- endif -%}
|
||||||
|
<div class="actions-group">{% if caller %}{{- caller() -}}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro pager(current_page, page_count, classes='', url='', args={}) -%}
|
||||||
|
{%- set args = dict(args.items() | rejectattr(0, 'equalto', 'page')) -%}
|
||||||
|
{%- if args -%}
|
||||||
|
{#- remove the page query argument -#}
|
||||||
|
{%- set url = url + (args | dict_to_query_string) + '&page=' -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set url = url + '?page=' -%}
|
||||||
|
{%- endif -%}
|
||||||
|
<span class="button-row {{classes}}">
|
||||||
|
{%- if current_page == 0 -%}
|
||||||
|
{%- if page_count <= 3 -%}
|
||||||
|
{%- for i in range(page_count) -%}
|
||||||
|
<a href="{{url}}{{i+1}}" class="linkbutton minimal">{{i+1}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- else -%}
|
||||||
|
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||||
|
<a href="{{url}}2" class="linkbutton minimal">2</a>
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
<a href="{{url}}{{page_count - 1}}" class="linkbutton minimal">{{page_count - 1}}</a>
|
||||||
|
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set left_start = [2, current_page - 1] | max -%}
|
||||||
|
{%- set right_end = [page_count - 1, current_page + 1] | min -%}
|
||||||
|
|
||||||
|
{%- if current_page != 1 -%}
|
||||||
|
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if left_start > 2 -%}
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- for i in range(left_start, current_page) -%}
|
||||||
|
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{%- if page_count > 0 -%}
|
||||||
|
<button class="minimal" disabled>{{current_page}}</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- for i in range(current_page + 1, right_end + 1) -%}
|
||||||
|
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{%- if right_end < page_count - 1 -%}
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if page_count > 1 and current_page != page_count -%}
|
||||||
|
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro tabs(prefix='', labels = []) -%}
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
{%- for tab_label in labels -%}
|
||||||
|
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}">{{tab_label}}</button>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- for tab_label in labels -%}
|
||||||
|
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}">
|
||||||
|
{{- caller(loop.index0) -}}
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro babycode_editor_component(
|
||||||
|
placeholder='Post content',
|
||||||
|
prefill='',
|
||||||
|
required=true,
|
||||||
|
id='babycode-content'
|
||||||
|
) -%}
|
||||||
|
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
|
||||||
|
{%- if idx == 0 -%}
|
||||||
|
<span class="babycode-editor-controls">
|
||||||
|
<span class="button-row">
|
||||||
|
<button type="button" class="minimal"><b>B</b></button>
|
||||||
|
<button type="button" class="minimal"><i>i</i></button>
|
||||||
|
<button type="button" class="minimal"><s>S</s></button>
|
||||||
|
<button type="button" class="minimal"><u>U</u></button>
|
||||||
|
<button type="button" class="minimal"><code>://</code></button>
|
||||||
|
<button type="button" class="minimal"><code></></code></button>
|
||||||
|
<button type="button" class="minimal">1.</button>
|
||||||
|
<button type="button" class="minimal">•</button>
|
||||||
|
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||||
|
</span>
|
||||||
|
<a href="##">babycode help</a>
|
||||||
|
</span>
|
||||||
|
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro full_post(
|
||||||
|
post, render_sig=true, is_latest=false,
|
||||||
|
show_toolbar=true, is_editing=false, thread=none,
|
||||||
|
show_reactions=true
|
||||||
|
) -%}
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set show_toolbar = false -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set owns = is_logged_in() and post.user_id == get_active_user().id -%}
|
||||||
|
{%- set can_reply = (is_logged_in()) and (not thread.locked or is_mod()) -%}
|
||||||
|
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<img src="{{post.avatar_path}}" class="avatar">
|
||||||
|
<div class="usercard-rest">
|
||||||
|
<a href="{{url_for('users.user_page', username=post.username)}}">{{post.display_name if post.display_name else post.username}}</a>
|
||||||
|
<abbr title="mention">@{{post.username}}</abbr>
|
||||||
|
<i>{{post.status}}</i>
|
||||||
|
{%- set badges=post.badges_json | fromjson -%}
|
||||||
|
<div class="badges-container">
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
|
<img src="{{badge.file_path}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||||
|
{%- if badge.link -%}</a>{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-content">
|
||||||
|
<div class="plank even minimal secondary-bg no-shadow post-info">
|
||||||
|
<a href="{{get_post_url(post.id, _anchor=true)}}"><i>Posted on {{timestamp(post.created_at)}}</i></a>
|
||||||
|
{%- if show_toolbar -%}
|
||||||
|
<span class="thread-actions">
|
||||||
|
{%- if owns -%}
|
||||||
|
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id)}}">Edit</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if can_reply -%}
|
||||||
|
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if can_delete -%}
|
||||||
|
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
|
||||||
|
{%- endif -%}
|
||||||
|
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark…</button>
|
||||||
|
</span>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
<div class="plank even no-shadow post-content-inner minimal">{{post.content | safe}}
|
||||||
|
{%- if render_sig and post.signature_rendered -%}
|
||||||
|
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- if show_reactions -%}
|
||||||
|
<div class="plank even secondary-bg minimal no-shadow">
|
||||||
|
<span class="button-row">
|
||||||
|
{%- for reaction in Reactions.for_post(post.id) -%}
|
||||||
|
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
|
||||||
|
{% set reactors_trimmed = reactors[:10] %}
|
||||||
|
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
||||||
|
{% if reactors | count > 10 %}
|
||||||
|
{% set reactors_str = reactors_str + '\n...and many others' %}
|
||||||
|
{% endif %}
|
||||||
|
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
|
||||||
|
<button disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
|
||||||
|
{%- endfor -%}
|
||||||
|
</span>
|
||||||
|
{%- if is_logged_in() -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
26
app/templates/common/topnav.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<nav id="header" class="plank top">
|
||||||
|
<a class="site-title" href="/">Porom</a>
|
||||||
|
<span>anti-social media</span>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
{%- with user = get_active_user() -%}
|
||||||
|
<ul class="horizontal wrap">
|
||||||
|
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
|
||||||
|
{% if user.is_mod() -%}
|
||||||
|
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||||
|
{%- endif %}
|
||||||
|
</ul>
|
||||||
|
{%- endwith -%}
|
||||||
|
{%- elif request.path != url_for('users.sign_up') and request.path != url_for('users.log_in') -%}
|
||||||
|
<form class="horizontal wrap" method="POST" action="{{url_for('users.log_in_post')}}">
|
||||||
|
<input type="hidden" name="return_to" value="{{request.path}}">
|
||||||
|
<input type="text" placeholder="Username" name="username" autocomplete="username" required>
|
||||||
|
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
|
||||||
|
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||||
|
<input type="submit" value="Log in">
|
||||||
|
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
|
||||||
|
</form>
|
||||||
|
{%- endif -%}
|
||||||
|
</nav>
|
||||||
13
app/templates/mod/edit_topic.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}editing topic {{topic.name}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('Editing topic %s' % topic.name, 'To preserve history, the URL of the topic can not be changed.')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required value="{{topic.name}}">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea name="description" id="description" rows="5" required>{{topic.description}}</textarea>
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
13
app/templates/mod/new_topic.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}creating a topic{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('Create topic', 'The new topic will appear at the bottom of the current topic list. You can sort it later.')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea name="description" id="description" rows="5" required></textarea>
|
||||||
|
<input type="submit" value="Create">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
19
app/templates/threads/new_thread.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}drafting a thread{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('New thread')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="topic">Topic</label>
|
||||||
|
<select name="topic_id" id="topic" autocomplete="off">
|
||||||
|
{%- for topic in topics -%}
|
||||||
|
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</select>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" required>
|
||||||
|
<label for="babycode-content">Starting post</label>
|
||||||
|
{{ babycode_editor_component() }}
|
||||||
|
<input type="submit" value="Create">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
77
app/templates/threads/thread.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||||
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set td -%}
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li>
|
||||||
|
{%- if thread.locked() or thread.stickied() -%}
|
||||||
|
{%- if thread.locked() -%}
|
||||||
|
<li class="visible">Locked</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if thread.stickied() -%}
|
||||||
|
<li class="visible">Stickied</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader(thread.title, td) -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
<button>Subscribe</button>
|
||||||
|
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||||
|
</fieldset>
|
||||||
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="lock" value="{{(not thread.locked()) | int}}">
|
||||||
|
<input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}">
|
||||||
|
<input type="submit" class="warn" value="{{'Unlock' if thread.locked() else 'Lock'}}" formaction="{{url_for('mod.lock_thread', thread_id=thread.id)}}">
|
||||||
|
<input type="submit" class="warn" value="{{'Unsticky' if thread.stickied() else 'Sticky'}}" formaction="{{url_for('mod.sticky_thread', thread_id=thread.id)}}">
|
||||||
|
</form>
|
||||||
|
<form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
|
||||||
|
<select name="new_topic_id" id="new-topic-id" autocomplete="off" required>
|
||||||
|
<option selected disabled value="">Move to topic:</option>
|
||||||
|
{%- for t in topics -%}
|
||||||
|
<option value="{{t.id}}" {{'disabled' if t.id==topic.id else ''}}>{{t.name}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="Move" class="warn">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<main>
|
||||||
|
{%- for post in posts -%}
|
||||||
|
<article id="post-{{post.id}}" class="post plank">
|
||||||
|
{{full_post(post)}}
|
||||||
|
</article>
|
||||||
|
{%- endfor -%}
|
||||||
|
</main>
|
||||||
|
<div class="plank secondary-bg">
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form">
|
||||||
|
<h2 class="info">Reply to "{{thread.title}}"</h2>
|
||||||
|
{{- babycode_editor_component() -}}
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" checked name="subscribe" id="subscribe">
|
||||||
|
<label for="subscribe">Subscribe to thread</label>
|
||||||
|
</span>
|
||||||
|
<span><input type="submit" value="Post reply"></span>
|
||||||
|
</form>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
70
app/templates/topics/topic.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% from 'common/macros.html' import timestamp, subheader, pager %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set td -%}
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li>{{topic.description}}</li>
|
||||||
|
{%- if topic.locked() -%}
|
||||||
|
<li class="visible">Locked</li>
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%}
|
||||||
|
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
|
||||||
|
{%- endif -%}
|
||||||
|
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||||
|
<form method="GET">
|
||||||
|
<select name="sort_by">
|
||||||
|
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||||
|
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="Sort">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
|
||||||
|
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
|
||||||
|
<input type="hidden" value="{{(not topic.locked()) | int}}" name="lock">
|
||||||
|
<input type="submit" class="warn" value="{{'Unlock' if topic.locked() else 'Lock'}}">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if threads | length > 0 -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count, args=request.args) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- if threads | length == 0 -%}
|
||||||
|
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for thread in threads -%}
|
||||||
|
<div class="topic-info plank">
|
||||||
|
<div class="title-container">
|
||||||
|
<span class="info thread-title-counter"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></span>
|
||||||
|
<ul class="horizontal"></ul>
|
||||||
|
{%- if thread.posts_count / 10 > 1 -%}
|
||||||
|
{{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread_by_id', thread_id=thread.id))}}
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
<span>Started by <a href="{{url_for('users.user_page', username=thread.started_by)}}">{{thread.started_by_display_name if thread.started_by_display_name else thread.started_by}}</a> on {{timestamp(thread.created_at)}}</span>
|
||||||
|
<span>{{thread.posts_count}} {{'repl' | pluralize(thread.posts_count, 'y', 'ies')}}</span>
|
||||||
|
<span>Latest post by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a>{{' (OP)' if thread.posts_count == 1 else ''}}</span>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- if threads | length > 0 -%}
|
||||||
|
<div class="plank secondary-bg">
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count, args=request.args) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
32
app/templates/topics/topics.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% from 'common/macros.html' import timestamp, subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader('All topics') -%}
|
||||||
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||||
|
<a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- for topic in topics -%}
|
||||||
|
<div class="topic-info plank">
|
||||||
|
<div class="title-container">
|
||||||
|
<a class="info" href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a>
|
||||||
|
</div>
|
||||||
|
<div>{{topic.description}}</div>
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li>{{topic.threads_count}} {{"thread" | pluralize(topic.threads_count)}}</li>
|
||||||
|
<li>{{topic.posts_count}} {{"post" | pluralize(topic.posts_count)}}</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
{%- if topic.latest_post_timestamp -%}
|
||||||
|
Latest post at: {{timestamp(topic.latest_post_timestamp)}}
|
||||||
|
{%- else -%}
|
||||||
|
No posts yet
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endblock -%}
|
||||||
22
app/templates/users/log_in.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% from 'common/macros.html' import subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}log in{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set welcome -%}
|
||||||
|
Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||||
|
{%- endset -%}
|
||||||
|
{{ subheader('Log in', welcome)}}
|
||||||
|
{%- if request.args.get('error') -%}
|
||||||
|
<div class="infobox plank critical">
|
||||||
|
{{request.args.get('error')}}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||||
|
<input type="submit" value="Log in">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
24
app/templates/users/sign_up.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% from 'common/macros.html' import subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}sign up{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set welcome -%}
|
||||||
|
Please read the rules etc. stub
|
||||||
|
{%- endset -%}
|
||||||
|
{{ subheader('Sign up', welcome)}}
|
||||||
|
{%- if request.args.get('error') -%}
|
||||||
|
<div class="infobox plank critical">
|
||||||
|
{{request.args.get('error')}}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
|
||||||
|
<label for="password">Create password</label>
|
||||||
|
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||||
|
<label for="password2">Confirm password</label>
|
||||||
|
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||||
|
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||||
|
<input type="submit" value="Sign up">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
98
app/templates/users/user_page.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, timestamp, pager -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- endblock -%}
|
||||||
|
{%- set stats = target_user.get_post_stats() -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader("%s's profile" % target_user.get_readable_name()) -%}
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
|
||||||
|
{%- if target_user.id == get_active_user().id -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<form action="{{url_for('users.log_out')}}" method="POST">
|
||||||
|
<input type="submit" class="warn" value="Log out">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if get_active_user().is_mod() and target_user.id != get_active_user().id -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<form method="POST">
|
||||||
|
{{csrf_input() | safe}}
|
||||||
|
{%- if target_user.is_guest() -%}
|
||||||
|
<input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
|
||||||
|
{%- else -%}
|
||||||
|
<input class="warn" type="submit" value="Demote to guest (soft ban)" formaction="{{url_for('mod.make_user_guest', user_id=target_user.id)}}">
|
||||||
|
{%- if get_active_user().is_admin() -%}
|
||||||
|
{%- if not target_user.is_mod_only() -%}
|
||||||
|
<input class="warn" type="submit" value="Promote to moderator" formaction="{{url_for('mod.make_user_mod', user_id=target_user.id)}}">
|
||||||
|
{%- else -%}
|
||||||
|
<input class="warn" type="submit" value="Demote from moderator" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<div class="userpage-usercard">
|
||||||
|
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<img src="{{target_user.get_avatar_url()}}" class="avatar">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plank even minimal no-shadow user-stats">
|
||||||
|
<h3 class="info">{{target_user.get_readable_name()}}</h3>
|
||||||
|
<span>Display name: {{target_user.get_readable_name()}}</span>
|
||||||
|
<span>Mention: @{{target_user.username}}</span>
|
||||||
|
<span>Status: <em>{{target_user.status}}</em></span>
|
||||||
|
<span>Rank: {{target_user.permission | permission_string}}</span>
|
||||||
|
{%- set time = target_user.created_at -%}
|
||||||
|
{%- if target_user.approved_at -%}
|
||||||
|
{%- set time = target_user.approved_at -%}
|
||||||
|
{%- endif -%}
|
||||||
|
<span>Joined: {{timestamp(target_user.created_at)}}</span>
|
||||||
|
{%- if not target_user.is_guest() -%}
|
||||||
|
<span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span>
|
||||||
|
<span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span>
|
||||||
|
{%- set badges = target_user.get_badges() -%}
|
||||||
|
|
||||||
|
{%- if badges -%}
|
||||||
|
<div class="badges-container nocenter">
|
||||||
|
Badges:
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
|
<img src="{{badge.get_image_url()}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||||
|
{%- if badge.link -%}</a>{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>About me</legend>
|
||||||
|
<p>stub</p>
|
||||||
|
</fieldset>
|
||||||
|
{%- if target_user.signature_rendered -%}
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>Signature</legend>
|
||||||
|
{{target_user.signature_rendered | safe}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{#
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>Profile comments</legend>
|
||||||
|
<fieldset class="plank minimal even no-shadow">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{pager(0, 3, url=url_for('users.log_in'))}}
|
||||||
|
</fieldset>
|
||||||
|
<div class="post plank">
|
||||||
|
<p>stub</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
#}
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
26
app/util.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from flask import url_for, session
|
||||||
|
from .models import Posts, Threads
|
||||||
|
from .auth import is_logged_in
|
||||||
|
|
||||||
|
def get_post_url(post_id, _anchor=False, external=False):
|
||||||
|
post = Posts.find({'id': post_id})
|
||||||
|
if not post:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
thread = Threads.find({'id': post.thread_id})
|
||||||
|
|
||||||
|
anchor = None if not _anchor else f'post-{post_id}'
|
||||||
|
|
||||||
|
return url_for('threads.thread_by_id', thread_id=thread.id, after=post_id, _external=external, _anchor=anchor)
|
||||||
|
|
||||||
|
def dict_to_query_string(d) -> str:
|
||||||
|
return '?' + '&'.join([f'{key}={str(value)}' for key, value in d.items()])
|
||||||
|
|
||||||
|
def get_csrf_token():
|
||||||
|
if not is_logged_in():
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return session.get('csrf', '')
|
||||||
|
|
||||||
|
def csrf_input():
|
||||||
|
return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">'
|
||||||
38
config/pyrom_config.toml.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
### REQUIRED CONFIGURATION
|
||||||
|
## the following settings are required.
|
||||||
|
## the app will not work if they are missing.
|
||||||
|
|
||||||
|
# the domain name you will be serving Pyrom from, without the scheme, including the subdomain(s).
|
||||||
|
# this is overridden by the app in development.
|
||||||
|
# used for generating URLs.
|
||||||
|
# the app will not start if this field is missing.
|
||||||
|
SERVER_NAME = "forum.your.domain"
|
||||||
|
|
||||||
|
### OPTIONAL CONFIGURATION
|
||||||
|
## the following settings are set to their default values.
|
||||||
|
## you can override any of them.
|
||||||
|
|
||||||
|
# your forum's name, shown on the header.
|
||||||
|
SITE_NAME = "Pyrom"
|
||||||
|
|
||||||
|
# if true, users can not sign up manually. see the following two settings.
|
||||||
|
DISABLE_SIGNUP = false
|
||||||
|
|
||||||
|
# if neither of the following two options is true,
|
||||||
|
# no one can sign up. this may be useful later when/if LDAP is implemented.
|
||||||
|
|
||||||
|
# if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||||
|
MODS_CAN_INVITE = true
|
||||||
|
|
||||||
|
# if true, allows users to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||||
|
USERS_CAN_INVITE = false
|
||||||
|
|
||||||
|
# contact information, will be shown in /guides/contact
|
||||||
|
# some babycodes allowed
|
||||||
|
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||||
|
ADMIN_CONTACT_INFO = ""
|
||||||
|
|
||||||
|
# forum information. shown in the introduction guide at /guides/user/introduction
|
||||||
|
# some babycodes allowed
|
||||||
|
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||||
|
GUIDE_DESCRIPTION = ""
|
||||||
BIN
data/static/badges/link-bsky.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
data/static/badges/link-itch-io.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
data/static/badges/link-mastodon.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
data/static/badges/link-www.webp
Normal file
|
After Width: | Height: | Size: 1000 B |
BIN
data/static/badges/pride-asexual.webp
Normal file
|
After Width: | Height: | Size: 256 B |
BIN
data/static/badges/pride-bisexual.webp
Normal file
|
After Width: | Height: | Size: 366 B |
BIN
data/static/badges/pride-intersex.webp
Normal file
|
After Width: | Height: | Size: 682 B |
BIN
data/static/badges/pride-lesbian.webp
Normal file
|
After Width: | Height: | Size: 394 B |
BIN
data/static/badges/pride-nonbinary.webp
Normal file
|
After Width: | Height: | Size: 274 B |
BIN
data/static/badges/pride-progress.webp
Normal file
|
After Width: | Height: | Size: 756 B |
BIN
data/static/badges/pride-six.webp
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
data/static/badges/pride-trans.webp
Normal file
|
After Width: | Height: | Size: 402 B |
BIN
data/static/badges/pronoun-any-all.webp
Normal file
|
After Width: | Height: | Size: 676 B |
BIN
data/static/badges/pronoun-fae-faer.webp
Normal file
|
After Width: | Height: | Size: 772 B |
BIN
data/static/badges/pronoun-he-him.webp
Normal file
|
After Width: | Height: | Size: 616 B |
BIN
data/static/badges/pronoun-it-its.webp
Normal file
|
After Width: | Height: | Size: 582 B |
BIN
data/static/badges/pronoun-no-pronouns.webp
Normal file
|
After Width: | Height: | Size: 850 B |
BIN
data/static/badges/pronoun-she-her.webp
Normal file
|
After Width: | Height: | Size: 690 B |
BIN
data/static/badges/pronoun-they-them.webp
Normal file
|
After Width: | Height: | Size: 842 B |
BIN
data/static/badges/pronoun-xe-xem.webp
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
data/static/badges/pronoun-xe-xir.webp
Normal file
|
After Width: | Height: | Size: 620 B |
223
data/static/css/normalize.css
vendored
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Document
|
||||||
|
* ========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a better box model (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||||
|
* 2. Correct the line height in all browsers.
|
||||||
|
* 3. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
* 4. Use a more readable tab size (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji'; /* 1 */
|
||||||
|
line-height: 1.15; /* 2 */
|
||||||
|
-webkit-text-size-adjust: 100%; /* 3 */
|
||||||
|
tab-size: 4; /* 4 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sections
|
||||||
|
* ========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the margin in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Text-level semantics
|
||||||
|
* ====================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font weight in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||||
|
* 2. Correct the odd 'em' font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family:
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Consolas,
|
||||||
|
'Liberation Mono',
|
||||||
|
Menlo,
|
||||||
|
monospace; /* 1 */
|
||||||
|
font-size: 1em; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tabular data
|
||||||
|
* ============
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||||
|
*/
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-color: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Forms
|
||||||
|
* =====
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Change the font styles in all browsers.
|
||||||
|
* 2. Remove the margin in Firefox and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit; /* 1 */
|
||||||
|
font-size: 100%; /* 1 */
|
||||||
|
line-height: normal; /* 1 */
|
||||||
|
margin: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct vertical alignment in Chrome and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the cursor style of increment and decrement buttons in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
* 2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield; /* 1 */
|
||||||
|
outline-offset: -2px; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
* 2. Change font properties to 'inherit' in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
font: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Interactive
|
||||||
|
* ===========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
774
data/static/css/style.css
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
@import url("/static/css/normalize.css");
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
src: url("/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf");
|
||||||
|
font-weight: 125 950;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
src: url("/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf");
|
||||||
|
font-weight: 125 950;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--base-padding: 6px;
|
||||||
|
--border-radius: 3px;
|
||||||
|
--border-thickness: 1px;
|
||||||
|
--wrapper-side-margin: 36px;
|
||||||
|
|
||||||
|
/* colors */
|
||||||
|
--bg-color-primary: #c1ceb1;
|
||||||
|
--bg-color-secondary: #aeb8a1;
|
||||||
|
--bg-color-tertiary: #797976;
|
||||||
|
--bg-color-contrast: #bfb1ce;
|
||||||
|
|
||||||
|
--font-color-main: black;
|
||||||
|
--font-color-anti: white;
|
||||||
|
--font-color-link: #c11c1c;
|
||||||
|
--font-color-link-visited: hsl(from var(--font-color-link) h calc(s * 0.5) calc(l * 0.7));
|
||||||
|
|
||||||
|
--critical-color: #f73030;
|
||||||
|
--warn-color: #dfdf61;
|
||||||
|
--infobox-color: #97b3ec;
|
||||||
|
|
||||||
|
--button-color-primary: #b1cecd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
--small-padding: calc(var(--base-padding) / 2);
|
||||||
|
--medium-padding: calc(var(--base-padding) * 2);
|
||||||
|
--big-padding: calc(var(--base-padding) * 3);
|
||||||
|
--huge-padding: calc(var(--base-padding) * 4);
|
||||||
|
|
||||||
|
--code-bg-color: hsl(from var(--bg-color-primary) h calc(s * 0.2) calc(l * 0.2));
|
||||||
|
|
||||||
|
background-color: var(--bg-color-tertiary);
|
||||||
|
font-family: Cadman;
|
||||||
|
color: var(--font-color-main);
|
||||||
|
margin: var(--big-padding) var(--wrapper-side-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .linkbutton, input[type="submit"] {
|
||||||
|
--main-color: var(--button-color-primary);
|
||||||
|
--font-color: var(--font-color-main);
|
||||||
|
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||||
|
--hover-color: hsl(from var(--main-color) h s calc(l * 1.05));
|
||||||
|
--active-color: hsl(from var(--main-color) h s calc(l * 0.8));
|
||||||
|
--disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l);
|
||||||
|
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7));
|
||||||
|
--top-color: hsl(from var(--main-color) h s calc(l * 1.2));
|
||||||
|
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
|
||||||
|
--inset-color: #fff7;
|
||||||
|
/* position: relative; */
|
||||||
|
/* display: inline-block; */
|
||||||
|
padding: var(--small-padding) var(--big-padding);
|
||||||
|
margin: var(--base-padding) 0px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: solid var(--border-thickness) var(--border-color);
|
||||||
|
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%);
|
||||||
|
box-shadow: inset 0px 2px 5px 3px var(--inset-color);
|
||||||
|
color: var(--font-color);
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
line-height: normal;
|
||||||
|
display: inline flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.minimal {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
--main-color: var(--critical-color);
|
||||||
|
--font-color: var(--font-color-anti);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
--main-color: var(--warn-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rss {
|
||||||
|
--main-color: #fba668;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alt {
|
||||||
|
--main-color: var(--bg-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--hover-color) 26%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:active, .active, [aria-selected='true']) {
|
||||||
|
background: linear-gradient(var(--active-color) 0%, var(--active-color) 50%, var(--main-color) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--disabled-color);
|
||||||
|
--inset-color: #fff3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
|
&[aria-selected='true'] {
|
||||||
|
padding-top: calc(var(--base-padding) * 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
margin-top: auto;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="password"], textarea, select {
|
||||||
|
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
|
||||||
|
--active-color: hsl(from var(--main-color) h s calc(l + 5));
|
||||||
|
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||||
|
background-color: var(--main-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: solid var(--border-thickness) var(--border-color);
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
padding: var(--small-padding) var(--medium-padding);
|
||||||
|
margin: var(--base-padding) 0px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--active-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: 'Atkinson Hyperlegible Mono'
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a:link) {
|
||||||
|
color: var(--font-color-link);
|
||||||
|
}
|
||||||
|
:where(a:visited) {
|
||||||
|
color: var(--font-color-link-visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.site-title {
|
||||||
|
font-family: site-title;
|
||||||
|
font-size: 3em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--font-color-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
background-color: var(--bg-color-primary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&>.site-title {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&>ul {
|
||||||
|
margin-top: var(--base-padding);
|
||||||
|
margin-bottom: var(--base-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plank {
|
||||||
|
--main-color: var(--bg-color-primary);
|
||||||
|
--lighter-color: hsl(from var(--main-color) h s calc(l*1.1));
|
||||||
|
--darker-color: hsl(from var(--main-color) h s calc(l*0.9));
|
||||||
|
--border-color: hsl(from var(--main-color) h s 90);
|
||||||
|
--rotation: 180deg;
|
||||||
|
padding: var(--medium-padding) var(--huge-padding);
|
||||||
|
|
||||||
|
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 30%, var(--main-color) 70%, var(--darker-color) 100%);
|
||||||
|
background-color: var(--main-color);
|
||||||
|
|
||||||
|
border: 2px groove var(--border-color);
|
||||||
|
|
||||||
|
&:not(.no-shadow) {
|
||||||
|
box-shadow: 0px 6px 3px 0px #0004;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minimal {
|
||||||
|
padding: var(--small-padding) var(--big-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-bottom: var(--small-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
border-top-left-radius: var(--border-radius);
|
||||||
|
border-top-right-radius: var(--border-radius);
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-bottom: var(--medium-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
border-bottom-left-radius: var(--border-radius);
|
||||||
|
border-bottom-right-radius: var(--border-radius);
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-top: var(--medium-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.horizontal {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&> fieldset {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: var(--small-padding);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px groove var(--border-color);
|
||||||
|
margin-top: var(--small-padding);
|
||||||
|
|
||||||
|
.plank:not(.secondary-bg) > & {
|
||||||
|
background-color: var(--bg-color-secondary);
|
||||||
|
}
|
||||||
|
.plank.secondary-bg > & {
|
||||||
|
background-color: var(--bg-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.horizontal, ol.horizontal {
|
||||||
|
display: inline flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
|
||||||
|
& li:not(.visible) {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li.visible {
|
||||||
|
margin-left: var(--big-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bullet li::before {
|
||||||
|
content: '\2022';
|
||||||
|
}
|
||||||
|
|
||||||
|
& li > button, li > .linkbutton {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-bg {
|
||||||
|
--main-color: var(--bg-color-primary);
|
||||||
|
background-color: var(--bg-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-bg {
|
||||||
|
--main-color: var(--bg-color-secondary);
|
||||||
|
--rotation: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tertiary-bg {
|
||||||
|
--main-color: var(--bg-color-tertiary);
|
||||||
|
--rotation: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrast-bg {
|
||||||
|
--main-color: var(--bg-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.motd {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contain-svg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox {
|
||||||
|
--main-color: var(--infobox-color);
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
--main-color: hsl(from var(--critical-color) h 50% calc(l * 0.7));
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
--main-color: hsl(from var(--warn-color) h 50% calc(l * 1.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 32px;
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-container {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
justify-content: start;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motd-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 75%
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
--grid-item-base-width: 600px;
|
||||||
|
--grid-item-max-width: calc((100% - var(--grid-item-base-width)) / 2);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(max(var(--grid-item-base-width), var(--grid-item-max-width)), 1fr));
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-last {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
padding: var(--base-padding);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min(230px, 20vw) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userpage-usercard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min(300px, 30vw) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
top: var(--big-padding);
|
||||||
|
position: sticky;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: min-content 1fr min-content;
|
||||||
|
&> * {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-signature {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 2px dotted gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-button {
|
||||||
|
min-width: 88px;
|
||||||
|
min-height: 31px;
|
||||||
|
max-width: 88px;
|
||||||
|
max-height: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--small-padding);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.nocenter {
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-rest {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-post-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--big-padding);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.full-width {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* babycode tags */
|
||||||
|
.inline-code {
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-big {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-small {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image {
|
||||||
|
max-height: 400px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&> button {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: unset;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mention {
|
||||||
|
--mention-color: var(--bg-color-contrast);
|
||||||
|
--hover-color: hsl(from var(--mention-color) h calc(s * 0.7) calc(l * 1.1));
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
background-color: var(--mention-color);
|
||||||
|
color: black;
|
||||||
|
border: 1px dashed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.me {
|
||||||
|
--mention-color: hsl(from var(--bg-color-contrast) calc(h + 90) s l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
--grid-item-base-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fill-flex {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post, .userpage-usercard {
|
||||||
|
grid-template-columns: unset;
|
||||||
|
grid-template-rows: min-content 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 180px;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-title-counter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-container {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-post-toast {
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image {
|
||||||
|
max-width: min(75vw, 400px);
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
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/scissors.png
Normal file
|
After Width: | Height: | Size: 236 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 |
BIN
data/static/favicon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf
Normal file
BIN
data/static/fonts/Cadman_Bold.woff2
Normal file
BIN
data/static/fonts/Cadman_BoldItalic.woff2
Normal file
BIN
data/static/fonts/Cadman_Italic.woff2
Normal file
BIN
data/static/fonts/Cadman_Roman.woff2
Normal file
BIN
data/static/fonts/ChicagoFLF.woff2
Normal file
@@ -3,6 +3,7 @@ events {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
flask
|
argon2-cffi==25.1.0
|
||||||
argon2-cffi
|
argon2-cffi-bindings==21.2.0
|
||||||
wand
|
blinker==1.9.0
|
||||||
dotenv
|
cachelib==0.13.0
|
||||||
|
cffi==1.17.1
|
||||||
|
click==8.2.1
|
||||||
|
Flask==3.1.1
|
||||||
|
Flask-Caching==2.3.1
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
pycparser==2.22
|
||||||
|
Pygments==2.19.2
|
||||||
|
python-dotenv==1.1.1
|
||||||
|
python-slugify==8.0.4
|
||||||
|
text-unidecode==1.3
|
||||||
|
Wand==0.6.13
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
|||||||