Compare commits

...

252 Commits

Author SHA1 Message Date
cd507ac25f rewrite topics routes to include id 2026-04-20 13:22:41 +03:00
82659cedef experimental: change signature render to <aside> tag 2026-04-20 13:22:41 +03:00
7eafcde1d7 rewrite threads routes to include id 2026-04-20 13:22:41 +03:00
a2ceaa0966 add some posts route annotations 2026-04-20 13:22:41 +03:00
f1931c76e6 disable buttons that will require js 2026-04-20 13:22:41 +03:00
65ad672748 restore page count to topics view 2026-04-20 13:22:41 +03:00
b9c4ec3911 improve fieldset legend legibility and set cursor to pointer on all buttons 2026-04-20 13:22:41 +03:00
0c2e920206 add csrf protection 2026-04-19 12:57:59 +03:00
9682295dae start user page and stub more endpoints 2026-04-19 10:03:03 +03:00
f798bb5d7d add forbidden usernames 2026-04-19 07:17:07 +03:00
68958e304b raise username length cap 2026-04-17 10:55:04 +03:00
d2cdeaed1d ensure trailing slashes in all routes 2026-04-17 10:45:54 +03:00
9d8404b774 add user signup flow 2026-04-17 10:45:37 +03:00
84e69187ff solve minor annoyance in pager macro where it would do ?&page= instead of ?page= when args was empty 2026-04-17 10:24:04 +03:00
0e71f597c9 lowercase username input in login form 2026-04-17 06:42:44 +03:00
76d600f01d add login route 2026-04-17 06:34:45 +03:00
54ed6fef3a add new thread route 2026-04-17 06:33:40 +03:00
7c0cb623e3 rework session handling 2026-04-17 05:25:29 +03:00
9c4f271259 add most mod routes 2026-04-16 23:11:19 +03:00
d6b44da6c2 basic posting 2026-04-16 00:01:18 +03:00
d0daaf4494 thread page mostly finished 2026-04-15 23:11:24 +03:00
7db111d18b make babycode [img] tags inline 2026-04-15 03:15:31 +03:00
dd54f5fe33 new babycode format for new style 2026-04-13 23:33:54 +03:00
4aa4e58c58 start stubbing out endpoints 2026-04-13 20:04:06 +03:00
ce9bca0a75 start the new topics route and view 2026-04-12 23:40:13 +03:00
099b5c135e add a legacy credits section to THIRDPARTY.md 2026-04-12 23:34:55 +03:00
5d53a0d179 change most double quotes to single quotes 2026-04-12 21:56:03 +03:00
f31752797e import css from redesign project 2026-04-12 21:05:12 +03:00
0b845b75c4 delete js files 2026-04-12 21:05:12 +03:00
af57e2f10c a fresh start :) 2026-04-12 08:48:21 +03:00
40219f2b54 clean stale sessions 2025-12-20 20:11:44 +03:00
4a45b62521 prevent admin from deleting their account 2025-12-20 19:05:01 +03:00
fc55aaf87a improve readme further 2025-12-20 18:52:15 +03:00
db68ef2c3d remove cache test endpoint 2025-12-20 18:42:52 +03:00
a808137e5b touch up the instructions and config example 2025-12-20 17:08:04 +03:00
a93a89f0df acknowledge Flask-Caching in THIRDPARTY.md 2025-12-20 17:04:44 +03:00
7aa3a9382e rss mention fix attempt; SITE_NAME is now required 2025-12-20 16:05:23 +03:00
46704df7d9 new sortable list implementation 2025-12-19 19:31:12 +03:00
98bf430604 fix inbox: show badges in inbox 2025-12-19 18:00:41 +03:00
21ace9299f make guide section more mobile friendly on portrait 2025-12-15 19:59:57 +03:00
122b706350 remove spaces from babycode html emoji, clean up js 2025-12-15 19:56:54 +03:00
c655caab9e use template element for badge template 2025-12-15 19:34:16 +03:00
b2d16e305d add bisexual pride badge 2025-12-15 19:25:14 +03:00
a8398cad51 make sure uwsgi is utf 8 2025-12-15 14:16:23 +03:00
f27d8eaf7e even more style changes 2025-12-15 05:40:17 +03:00
36e17c6677 clean up newlines in babycode.py 2025-12-14 08:40:38 +03:00
d7a90745f6 correct invalid void tags 2025-12-14 08:35:21 +03:00
d90b4643cb reorganize settings a bit 2025-12-14 08:16:54 +03:00
d82f25471d port to bitty 7.0.0 2025-12-14 08:16:28 +03:00
791911b416 change min settings column width to 600px 2025-12-14 08:05:14 +03:00
ba2c9132f6 and some styles 2025-12-14 07:19:49 +03:00
d4e3d7cded draw the rest of the owl 2025-12-14 07:16:10 +03:00
0898c56a51 add rss content to post history and generate it when creating or editing a post 2025-12-14 07:05:52 +03:00
96c37f9081 add flask-cache dep 2025-12-13 23:31:50 +03:00
94a4be8b97 remove erroneous dumps method from BabycodeRenderResult 2025-12-13 09:17:16 +03:00
fa1140895a use no fragment in some places in babycode guide 2025-12-13 07:37:54 +03:00
fc6c5d46e1 refactor babycode lib to have different code paths for html and rss-friendly generation 2025-12-13 07:36:49 +03:00
dc0aa0dba7 ascii... 2025-12-09 13:40:25 +03:00
dbf0150a5e add badges 2025-12-09 03:33:27 +03:00
1539486456 undo the roundness in snow white theme 2025-12-07 20:22:21 +03:00
c18dad4a77 raise on exception in connection, fix operator not being considered in QueryBuilder 2025-12-06 22:19:12 +03:00
2b45cab4e8 actually disallow @ in display name 2025-12-06 19:08:35 +03:00
37c1ffc2a1 strip animation from uploaded avatar 2025-12-06 18:09:15 +03:00
09a19b5352 raise overall content body size, routes will implement stricter limits 2025-12-06 10:18:32 +03:00
6c96563a0e fix title in 413 template 2025-12-06 06:15:51 +03:00
77677eef6d new theme: snow white 2025-12-05 18:22:25 +03:00
f99ae75503 excise settings-container and login-container outright. full width babey 2025-12-05 17:53:24 +03:00
552fb67c6c settings width is now 95% 2025-12-05 17:26:05 +03:00
e9c03b9046 add a background color to fieldset in settings grid 2025-12-05 17:23:35 +03:00
f0b0fb8909 handle 413 2025-12-05 17:00:32 +03:00
9ae4e376b8 load default site configuration first before merging file config 2025-12-05 16:55:13 +03:00
d1bc1c644b remove errant paragraph from introduction guide 2025-12-05 16:53:36 +03:00
7840399d01 untrack pyrom_config.toml, provide example with default values instead 2025-12-05 14:16:11 +03:00
508b313871 fix babycode guide links 2025-12-05 13:58:13 +03:00
db677abaa5 add a guide topics system 2025-12-05 13:53:21 +03:00
65abea2093 port to bitty 7.0.0-rc1 2025-12-05 04:31:03 +03:00
1533f82a6b oops 2025-12-04 10:27:49 +03:00
35483c27aa null is not a thing in python lol 2025-12-04 10:22:03 +03:00
005d2f3b6c fix signup 2025-12-04 10:19:49 +03:00
265e249eaf make guide sections a macro 2025-12-04 08:55:08 +03:00
b812e01473 add [lb], [rb], and [@] tags 2025-12-04 08:31:49 +03:00
88f80c38cc make babycode guide use generic class names 2025-12-04 06:24:24 +03:00
c70f13d069 add contact information to config 2025-12-04 06:16:37 +03:00
73af2dc3b9 forbid mentions in sigs 2025-12-04 05:36:57 +03:00
062cab44bc add Atkinson Hyperlegible Mono font for textarea, normalize css a bit 2025-12-04 05:21:19 +03:00
3baccb87b1 new settings page 2025-12-04 02:39:24 +03:00
3742749cf6 cachebust bitty script too 2025-12-03 10:15:22 +03:00
eb76338c4a add user account deletion (right to be forgotten) 2025-12-03 09:43:35 +03:00
9951ed3fae add a @redirect_to_own decorator in users app 2025-12-03 09:43:17 +03:00
a7876ca410 return 404 where it makes sense 2025-12-03 08:07:03 +03:00
7c037d1593 add custom 404 page 2025-12-03 06:37:57 +03:00
24fe0aba30 use MOTD_BANNED_TAGS in motd editor view 2025-12-02 06:23:06 +03:00
a185208fc1 finally make settings container wider on all themes 2025-12-02 06:22:47 +03:00
1d5d5a8c64 add mentions 2025-12-02 06:13:50 +03:00
414298b4b4 actually use the convertTimestamps bitty signal 2025-12-02 06:09:32 +03:00
c3a3ead852 add from_data() classmethod to Model 2025-12-02 05:56:49 +03:00
54907db896 make quote button allow preview without needing to edit 2025-12-02 05:43:34 +03:00
db2d09cb03 move date-fmt to the bitty script 2025-12-01 01:30:41 +03:00
5c03ba3d3a cachebust bitty 2025-11-29 07:38:46 +03:00
3a9f8a111b show something useful when preview is empty 2025-11-29 07:07:46 +03:00
bbe57d6e94 move over a bunch of ui functionality to bitty 2025-11-29 06:58:41 +03:00
0bed6b58ae make otomotone theme a bit rounder 2025-11-29 04:02:15 +03:00
8164e63b09 theme edits 2025-11-27 22:56:12 +03:00
8b5b38e38b render motds in topic and topics views 2025-11-27 22:25:03 +03:00
d0dfd3a4c3 re-parse MOTDs if required on init 2025-11-27 19:56:17 +03:00
fca214dfcf add motd schema and motd editor 2025-11-27 19:47:26 +03:00
04fd3f5d20 add css class for horizontal lists 2025-11-27 17:11:57 +03:00
1a3c015612 use before_request in mod app 2025-11-27 15:56:29 +03:00
fc9ae63471 add mod panel view 2025-11-27 15:28:17 +03:00
4d88b5c24c update bitty to 6.0.0-rc3 2025-11-27 15:01:06 +03:00
fefdbdb493 make top navlinks into <ul> 2025-11-27 03:04:05 +03:00
d0c82cf9a9 use sender in show bookmark bitty 2025-11-27 03:03:21 +03:00
90fe38497d make bookmark dropdown items better looking 2025-11-26 21:02:59 +03:00
97e2c041c9 use bitty 6 api's 2025-11-26 16:11:46 +03:00
bbbe152ff8 fix bookmark menu bg in otomotone 2025-11-26 16:06:35 +03:00
a3ad36e9a9 fix click on svg; keep dropdown svg's inlined and make them respect currentColor 2025-11-26 15:46:38 +03:00
48fcadf61e more accurate public domain attribution 2025-11-26 15:37:01 +03:00
62e1724f6c use URLSearchParams in hyperapi url constructor 2025-11-26 15:19:11 +03:00
19383a538d update bitty to 6.0.0-rc1 2025-11-26 15:18:44 +03:00
2d3eef6531 remove bookmark button from topic view 2025-11-23 22:30:55 +03:00
e874d41fbc make padding in post content consistent in default 2025-11-23 22:30:34 +03:00
844499383c add note about THIRDPARTY to readme 2025-11-23 22:16:04 +03:00
4e8b7d2172 linebreak in THIRDPARTY 2025-11-23 22:08:31 +03:00
075a9bd498 add ability to bookmark posts and threads, courtesy of bitty 2025-11-23 22:07:50 +03:00
962b833a80 vendor bitty 2025-11-23 22:06:10 +03:00
71b04ca4bd bookmark collections 2025-11-21 12:38:23 +03:00
831eb32b8a add bookmark button to thread and posts in thread view 2025-11-21 06:59:59 +03:00
10934c557d simplify contain-svg css 2025-11-21 06:07:23 +03:00
4b70ae1b43 remove broken require 2025-11-21 05:40:11 +03:00
729b7300e6 add bookmark button to threads in topic view 2025-11-21 05:39:13 +03:00
f8101e57c1 make icons macros instead of svg files 2025-11-21 05:38:51 +03:00
95decd9a56 add user bookmarks view 2025-11-21 02:40:11 +03:00
b86e049263 add bookmark schema 2025-11-20 03:43:43 +03:00
5233f2ef4c disallow inviting if invites are disabled 2025-11-20 01:25:49 +03:00
81183f2c02 add markup metadata to sig; reparse sigs on init 2025-11-19 23:07:36 +03:00
86cd55c25b compliance: list more projects in THIRDPARTY.md 2025-11-19 13:20:20 +03:00
a8013f7718 remove old dotenv req 2025-11-19 12:34:59 +03:00
d2bf93abe6 add pygments to requirements.txt 2025-10-13 00:14:28 +03:00
ef95da4d47 mention languages for code blocks in babycode help 2025-10-12 23:57:37 +03:00
661d1ee1b1 add code highlighting 2025-10-12 23:47:14 +03:00
98188c1c69 set correct path in uwsgi 2025-10-12 22:07:12 +03:00
64cfbbc057 bump babycode version 2025-08-17 21:11:19 +03:00
6cfc862d63 fix subscription unread count calculation 2025-08-17 20:52:19 +03:00
70646ba381 some edits for otomotone theme 2025-08-17 20:33:26 +03:00
f04f0fb51b new theme: peachy 2025-08-17 20:33:12 +03:00
317182ae12 build available theme list dynamically 2025-08-17 20:32:17 +03:00
751be27b52 unescape children of babycode elements 2025-08-17 15:15:49 +03:00
6dd9f5bf65 add unread count to thread title in topic view and thread view 2025-08-17 15:15:06 +03:00
1f80ed7ca5 fix oto's sig by setting min width and height on post images as well 2025-08-17 02:36:09 +03:00
89817340c9 add barebones theme switcher 2025-08-17 01:36:55 +03:00
fc80823713 add otomotone theme 2025-08-17 01:36:34 +03:00
184472726e set unbound in build-themes 2025-08-17 00:11:34 +03:00
68cf5f7d57 remove contents properly 2025-08-17 00:07:10 +03:00
4ef7b0ba1e add watch arg to build-themes.sh 2025-08-16 23:58:43 +03:00
aaeb3a524b make the sass file extendable 2025-08-16 23:44:02 +03:00
f1f62fa2c8 add spoiler button to babycode editor 2025-08-16 17:50:33 +03:00
8c917f6ae2 bump babycode version 2025-08-16 17:38:15 +03:00
4f88d14b45 babycode: handle code tag being inline or block explicitly in is_inline 2025-08-16 17:37:05 +03:00
9238385244 collapse whitespace between block babycode tags 2025-08-16 16:59:44 +03:00
cf89070639 put images in their own lane 2025-08-16 15:50:44 +03:00
4a8f87d64a fix wrong variable in edit post route 2025-08-16 06:08:08 +03:00
2b1f52a99d mention lightboxes in babycode guide 2025-08-16 05:46:44 +03:00
d0b702e1e8 allow spaces in babycode tag attrs 2025-08-16 05:40:59 +03:00
14b96bf37e add spoiler tag to babycode 2025-08-16 05:40:00 +03:00
cf4bf3caa3 add versioning to markup languages and reparse old format version posts 2025-08-16 05:28:44 +03:00
382080ceaa up to python 3.13 2025-08-16 02:22:04 +03:00
304a862931 try to bump libargon deb ver? 2025-08-16 01:26:02 +03:00
348b782350 fix loose links consuming spaces 2025-08-16 00:55:25 +03:00
aec4724e2f restore shadowban functionality to mods 2025-08-15 23:45:23 +03:00
53d39d5a36 properly redirect in set avatar 2025-08-15 23:34:45 +03:00
05bd034b23 redirect on post delete if target post cant be found 2025-08-15 23:23:55 +03:00
033df03c49 quote column names in db insert 2025-08-15 21:59:42 +03:00
a0c86f33b4 finish that a tag in topics view 2025-08-11 17:46:22 +03:00
712782bc1c add invite system 2025-08-11 17:26:15 +03:00
1c80777fe4 add config file 2025-08-10 19:31:00 +03:00
4c2877403d add a way for mods to create a password reset link for users 2025-08-10 19:00:47 +03:00
cf2d605077 delete thread when no more posts remain 2025-08-10 06:30:24 +03:00
c68ead85c0 add line breaks around [quote] in reply button reply markup 2025-08-09 12:48:04 +03:00
b0fd2a4f0c add [u], [big], [small], [color], [center], [right] tags to babycode 2025-08-05 01:25:38 +03:00
a529c1db65 add reactions support to thread 2025-08-04 21:10:23 +03:00
acac6ed778 make sure thread slug is accessible to post macro in inbox 2025-08-04 06:18:05 +03:00
3699daa44a add scissors in quoted fragment for fun 2025-08-04 04:10:50 +03:00
4bdd01569c add scissors emoji (homegrown) 2025-08-04 04:07:06 +03:00
33dc52342a acknowledge forumoji in THIRDPARTY.md 2025-08-04 04:05:47 +03:00
d3f63c4120 fix babycode code block generation 2025-08-04 03:29:56 +03:00
d36e94127e move copy-code.js into ui.js 2025-08-04 03:25:41 +03:00
e33d26c6dc make nested quotes stack opacity 2025-08-04 03:17:07 +03:00
7702384c40 cachebust style and js at build time 2025-08-04 03:14:33 +03:00
f08c60de75 fix undeclared get_active_user in mod app 2025-08-04 03:00:14 +03:00
2e8fd9a22e new inbox view 2025-08-04 02:57:51 +03:00
6e86832211 add border to footer 2025-08-04 02:17:51 +03:00
c7f29c1cd4 add accordion macro, new user list view 2025-08-04 02:17:27 +03:00
abcc10654b potentially fix logged out users getting an internal server error 2025-07-30 21:45:35 +03:00
3c1797afef properly escape alt in img tag 2025-07-30 18:47:58 +03:00
d006862422 add js to quote part of a post 2025-07-06 22:31:54 +03:00
e60c74a90f put emoji shortcodes in code blocks in guide 2025-07-06 19:47:05 +03:00
80fec756a9 add favicon 2025-07-06 19:44:55 +03:00
9d1cd01f69 add migration to delete old lua-porom sessions 2025-07-04 20:13:48 +03:00
59e40ed5fd re-add babycode guide 2025-07-04 20:03:00 +03:00
692a1d6b2b re-add emojis to babycode parser 2025-07-04 19:06:22 +03:00
443c25c09b re-add changing password 2025-07-04 18:45:53 +03:00
4cbc66d9aa make sure form doesn't make it possible to post to locked topic 2025-07-04 17:35:17 +03:00
7ab1c8745f replace stub returns with redirects 2025-07-04 17:24:17 +03:00
13c89cbde2 make it work in docker again and fix some ngx config issues 2025-07-02 20:47:10 +03:00
c41e0cfc0c 3.11 2025-07-02 20:46:48 +03:00
56c531b64e add migration to transform old avatar paths 2025-07-02 19:02:30 +03:00
d729924101 better migration handling 2025-07-02 18:51:45 +03:00
58dd9fb439 subscription fixes 2025-07-02 18:51:33 +03:00
7ef0b9dc7d add permission string filter 2025-07-02 18:51:13 +03:00
4f18694de3 style fixes 2025-07-02 16:22:52 +03:00
285c1cb119 fix subscribe by default setting not working 2025-07-02 16:22:45 +03:00
76da1c3e61 add posts route 2025-07-02 16:22:07 +03:00
3da3054587 remove erroneous instruction from readme 2025-07-02 13:36:56 +03:00
64e18f16dd re-add api route 2025-07-02 13:35:14 +03:00
3c3837b3f2 fix thread.js not passing 'since' correctly to post updates api 2025-07-02 13:34:45 +03:00
57a6810b03 add readme & thirdparty and freeze pip deps 2025-07-02 12:37:55 +03:00
395e86f179 extend db session so it's ahead of the cookie 2025-07-02 02:26:39 +03:00
dde1139eed add user settings 2025-07-02 02:23:11 +03:00
bd556d102b implement thread mod ops 2025-07-01 23:34:48 +03:00
29bb9872d3 re-add subscriptions 2025-07-01 23:20:36 +03:00
52f6484db1 add all mod actions on users 2025-07-01 21:26:52 +03:00
df239fb130 add stats to user page 2025-07-01 20:29:15 +03:00
2345830074 permalink to latest post in topic view 2025-07-01 19:09:50 +03:00
f9256b70db fix pager 2025-07-01 19:06:05 +03:00
18f4b026ea remove entered text from localstorage when sending response 2025-07-01 18:43:28 +03:00
c4ee9d883e move delete dialog and thread script into the content block in thread view 2025-07-01 18:35:16 +03:00
7aab6df74f add responding to thread 2025-07-01 18:32:49 +03:00
e0d1abc1e9 minor fixes 2025-07-01 18:27:52 +03:00
9ae55c92ba don't escape content in topic view 2025-07-01 18:11:40 +03:00
e5a140fa2e add thread view 2025-07-01 18:11:16 +03:00
e39ccd5939 add abbr to ST in timestamp 2025-07-01 18:10:29 +03:00
44a475dc87 fix datetime format filter 2025-07-01 18:08:14 +03:00
06799a5088 fix missing join in full post query 2025-07-01 18:07:24 +03:00
77b8172676 fix post stats being a cartesian product 2025-07-01 15:35:26 +03:00
04a59c8396 support lists of tuples in QueryBuilder 2025-07-01 15:21:29 +03:00
604f9d6aba creating threads 2025-07-01 14:14:29 +03:00
c7fb6784c4 port babycode transform function 2025-07-01 01:30:51 +03:00
a7f9fbfe90 finally properly handle transactions 2025-07-01 00:22:12 +03:00
320b898b29 actually link to the right repo 2025-06-30 22:34:46 +03:00
a12fd0a904 add commit to bottom nav 2025-06-30 22:34:19 +03:00
c22aa1036f port mod app 2025-06-30 22:13:12 +03:00
453aeff95a mostly implement topics app 2025-06-30 19:50:57 +03:00
19bf98f5b5 add timestamp macro 2025-06-30 17:11:24 +03:00
a95200caf9 move some templates into one macro 2025-06-30 16:55:44 +03:00
ea5ecc3a9f make sure session cookie is secure and has an expiration date 2025-06-30 16:53:20 +03:00
05cbc03e82 start porting some topics stuff 2025-06-30 16:13:23 +03:00
9126ce4f61 port babycode parser 2025-06-30 16:12:40 +03:00
fd257e701f start user page port 2025-06-30 12:47:24 +03:00
bd56310067 sign up 2025-06-29 23:54:29 +03:00
fb2a96e94d log in 2025-06-29 23:14:23 +03:00
dfb662c646 import static files 2025-06-29 21:11:19 +03:00
21b62a12f9 remove loose print from db 2025-06-29 21:10:47 +03:00
74 changed files with 4569 additions and 147 deletions

View File

@@ -4,5 +4,7 @@
data/db/*
data/static/avatars/*
!data/static/avatars/default.webp
data/static/badges/user
data/_cached
.local/

3
.gitignore vendored
View File

@@ -4,7 +4,10 @@
data/db/*
data/static/avatars/*
!data/static/avatars/default.webp
data/static/badges/user
data/_cached
config/secrets.prod.env
config/pyrom_config.toml
.local/

View File

@@ -5,11 +5,11 @@ RUN apt-get update && apt-get install -y \
uwsgi \
uwsgi-plugin-python3 \
sqlite3 \
libargon2-0 \
libargon2-1 \
imagemagick \
&& 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"
WORKDIR /app

113
README.md Normal file
View 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
View 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

View File

@@ -1,58 +1,217 @@
from flask import Flask
from flask import Flask, session, request, render_template, redirect, url_for
from dotenv import load_dotenv
from .models import Avatars, Users
from .auth import digest
from .constants import PermissionLevel
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 time
import secrets
import hmac
import tomllib
import json
def create_default_avatar():
if Avatars.count() == 0:
print("Creating default avatar reference")
print('Creating default avatar reference')
Avatars.create({
"file_path": "/static/avatars/default.webp",
"uploaded_at": int(time.time())
'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!!!!!")
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,
'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}) == 0:
print("Creating DeletedUser")
username = 'DeletedUser'
if Users.count({'username': username.lower()}) == 0:
print('Creating DeletedUser')
Users.create({
"username": username,
"password_hash": "",
"permission": PermissionLevel.SYSTEM.value,
'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():
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.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
app.config['CACHE_TYPE'] = 'FileSystemCache'
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.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()
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():
from .schema import create as create_tables
from .migrations import run_migrations
@@ -63,7 +222,133 @@ def create_app():
create_admin()
create_deleted_user()
from app.routes.app import bp as app_bp
app.register_blueprint(app_bp)
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

View File

@@ -1,7 +1,25 @@
from flask import session, flash, redirect, url_for, abort, request, current_app
from .models import Sessions, Users
from argon2 import PasswordHasher
from functools import wraps
import secrets
import hmac
import time
import re
ph = PasswordHasher()
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)
@@ -10,3 +28,97 @@ def verify(expected, given):
return ph.verify(expected, given)
except:
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

View File

@@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, IntEnum
class PermissionLevel(Enum):
GUEST = 0
@@ -6,3 +6,72 @@ class PermissionLevel(Enum):
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',
}

167
app/db.py
View File

@@ -4,13 +4,13 @@ from flask import current_app
class DB:
def __init__(self):
self._transaction_depth = 0
self._connection = None
self._transaction_depth = 0
@contextmanager
def _get_connection(self):
if self._connection and self._transaction_depth > 0:
def connection(self, in_transaction = False):
if self._connection:
yield self._connection
return
@@ -19,60 +19,44 @@ class DB:
conn.execute("PRAGMA FOREIGN_KEYS = 1")
try:
if in_transaction:
self._connection = conn
self._transaction_depth += 1
conn.execute("BEGIN")
yield conn
if in_transaction:
conn.commit()
except Exception as e:
if in_transaction and self._connection:
conn.rollback()
raise
finally:
if in_transaction:
self._transaction_depth -= 1
if self._transaction_depth == 0:
self._connection = None
conn.close()
@contextmanager
def transaction(self):
"""Transaction context."""
self.begin()
try:
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()
with self.connection(in_transaction=True) as conn:
yield conn
def query(self, sql, *args):
"""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()
return [dict(row) for row in rows]
def insert(self, table, columns, *values):
if isinstance(columns, (list, tuple)):
columns = ", ".join(columns)
columns = ", ".join([f'"{column}"' for column in columns])
placeholders = ", ".join(["?"] * len(values))
sql = f"""
@@ -81,7 +65,7 @@ class DB:
RETURNING *
"""
with self._get_connection() as conn:
with self.connection() as conn:
result = conn.execute(sql, values).fetchone()
conn.commit()
return dict(result) if result else None
@@ -89,14 +73,14 @@ class DB:
def execute(self, sql, *args):
"""Executes a query without returning."""
with self._get_connection() as conn:
with self.connection() as conn:
conn.execute(sql, args)
conn.commit()
def fetch_one(self, sql, *args):
"""Grabs the first row of a query."""
with self._get_connection() as conn:
with self.connection() as conn:
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
@@ -104,9 +88,35 @@ class DB:
class QueryBuilder:
def __init__(self, table):
self.table = table
self._where = {}
self._where = [] # list of tuples
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 = "*"):
@@ -114,40 +124,47 @@ class DB:
return self
def where(self, condition):
self._where.update(condition)
def where(self, condition, operator = "="):
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
def build_select(self):
sql = f"SELECT {self._select} FROM {self.table}"
if self._where:
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
sql += f" WHERE {conditions}"
return sql, list(self._where.values())
where_clause, params = self._build_where()
stmt = sql + where_clause
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):
columns = ", ".join(f"{k} = ?" for k in data.keys())
sql = f"UPDATE {self.table} SET {columns}"
if self._where:
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
sql += f" WHERE {conditions}"
params = list(data.values()) + list(self._where.values())
return sql, params
where_clause, where_params = self._build_where()
params = list(data.values()) + list(where_params)
return sql + where_clause, params
def build_delete(self):
sql = f"DELETE FROM {self.table}"
if self._where:
conditions = " AND ".join(f"{k} = ?" for k in self._where.keys())
sql += f" WHERE {conditions}"
return sql, list(self._where.values())
where_clause, params = self._build_where()
return sql + where_clause, params
def first(self):
sql, params = self.build_select()
print(sql, params)
return db.fetch_one(f"{sql} LIMIT 1", *params)
@@ -173,6 +190,13 @@ class Model:
raise AttributeError(f"No column '{key}'")
@classmethod
def from_data(cls, data):
instance = cls(cls.table)
instance._data = dict(data)
return instance
@classmethod
def find(cls, condition):
row = db.QueryBuilder(cls.table)\
@@ -180,9 +204,16 @@ class Model:
.first()
if not row:
return None
instance = cls(cls.table)
instance._data = dict(row)
return instance
return cls.from_data(row)
@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
@@ -194,9 +225,7 @@ class Model:
row = db.insert(cls.table, columns, *values.values())
if row:
instance = cls(cls.table)
instance._data = row
return instance
return cls.from_data(row)
return None
@@ -210,6 +239,14 @@ class Model:
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):
qb = db.QueryBuilder(self.table)\
.where({"id": self._data["id"]})

610
app/lib/babycode.py Normal file
View 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
View 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
View 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
View 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

View File

@@ -1,9 +1,50 @@
from .db import db
# format: {integer: str|list<str>}
MIGRATIONS = {
def migrate_old_avatars():
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():
db.execute("""
@@ -12,22 +53,25 @@ def run_migrations():
)
""")
if len(MIGRATIONS) == 0:
print("No migrations defined.")
print('No migrations defined.')
return
print("Running migrations...")
print('Running migrations...')
ran = 0
completed = [row["id"] for row in db.query("SELECT id FROM _migrations")]
for migration_id in sorted(MIGRATIONS.keys()):
if migration_id not in completed:
print(f"Running migration #{migration_id}")
completed = {int(row['id']) for row in db.query('SELECT id FROM _migrations')}
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
if not to_run:
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
statements = MIGRATIONS[migration_id]
# 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.")
print(f'Ran {ran} migrations.')

View File

@@ -1,25 +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"
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"
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"
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):
table = "posts"
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"
table = 'post_history'
class Sessions(Model):
table = "sessions"
table = 'sessions'
class Avatars(Model):
table = "avatars"
table = 'avatars'
class Subscriptions(Model):
table = "subscriptions"
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

View File

@@ -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.route("/")
def hello_world():
return f"<img src='static/avatars/default.webp'></img>"
@bp.get('/')
def index():
return redirect(url_for('topics.all_topics'))

11
app/routes/guides.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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'

View File

@@ -76,22 +76,122 @@ SCHEMA = [
"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
"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_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_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_topic_id ON threads(topic_id)",
"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 sessions_user_id ON sessions(user_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_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_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_topic_id ON threads(topic_id)',
'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 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():
print("Creating schema...")
print('Creating schema...')
with db.transaction():
for stmt in SCHEMA:
db.execute(stmt)
print("Schema completed.")
print('Schema completed.')

19
app/templates/base.html Normal file
View 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>

View 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 -%}

View 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>

View 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>&hellip;</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>&hellip;</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>&hellip;</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>&lt;/&gt;</code></button>
<button type="button" class="minimal">1.</button>
<button type="button" class="minimal">&bullet;</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&hellip;</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 %}

View 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>

View 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 -%}

View 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 -%}

View 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 -%}

View 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&hellip;</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 -%}

View 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 -%}

View 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 -%}

View 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 -%}

View 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 -%}

View 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
View 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()}">'

View 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 = ""

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

223
data/static/css/normalize.css vendored Normal file
View 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
View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

BIN
data/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,6 +3,7 @@ events {
}
http {
include /etc/nginx/mime.types;
server {
listen 8080;
server_name localhost;

View File

@@ -1,4 +1,18 @@
flask
argon2-cffi
wand
dotenv
argon2-cffi==25.1.0
argon2-cffi-bindings==21.2.0
blinker==1.9.0
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

View File

@@ -12,3 +12,7 @@ pythonpath = /opt/venv/lib/python3.13/site-packages
uid = www-data
gid = www-data
env = LANG=C.UTF-8
env = LANGUAGE=C.UTF-8
env = LC_ALL=C.UTF-8