Compare commits

...

198 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
94 changed files with 3795 additions and 4071 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -1,11 +1,11 @@
FROM python:3.11-slim FROM python:3.13-slim
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
nginx \ nginx \
uwsgi \ uwsgi \
uwsgi-plugin-python3 \ uwsgi-plugin-python3 \
sqlite3 \ sqlite3 \
libargon2-0 \ libargon2-1 \
imagemagick \ imagemagick \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -1,18 +1,70 @@
# Pyrom # Pyrom
python/flask port of [porom](https://git.poto.cafe/yagich/porom) pyrom is a playful home-grown forum software for the indie web borne out of frustration with social media and modern forums imitating it.
this is now the canonical implementation of porom. it's compatible with the database of porom. 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.)
# License a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording. ## 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 # installing & first time setup
## docker (production) ## docker (production)
create `config/secrets.prod.env` according to `config/secrets.prod.env.example` 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 ```bash
$ docker compose up $ chmod -R 777 data/
```
5. bring up the container:
```bash
$ docker compose up --build
``` ```
- opens port 8080 - opens port 8080
@@ -20,10 +72,10 @@ $ docker compose up
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. 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.
alternatively, if you already had porom running before, put the db file (`db.prod.sqlite`) in `data/db` and it will Just Work. 6. point your favorite proxy at `localhost:8080`
## manual (development) ## manual (development)
1. install python >= 3.11, sqlite3, libargon2, and imagemagick & clone repo 1. install python >= 3.13, sqlite3, libargon2, and imagemagick & clone repo
2. create a venv: 2. create a venv:
```bash ```bash
@@ -59,5 +111,3 @@ $ source .venv/bin/activate
$ python -m app.run $ python -m app.run
``` ```
# icons
the icons in the `data/static/misc/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)

View File

@@ -23,11 +23,83 @@ Copyright: `© 2017-2020 by Paul James Miller. All rights reserved.`
License: SIL Open Font License 1.1 License: SIL Open Font License 1.1
Designers: Paul James Miller 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 ## ICONCINO
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina Designers: Gabriele Malaspina
License: CC0 1.0/CC BY 4.0 License: CC0 1.0
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.

View File

@@ -1,67 +1,217 @@
from flask import Flask, session from flask import Flask, session, request, render_template, redirect, url_for
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Avatars, Users from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
from .auth import digest from .auth import digest, is_logged_in, get_active_user
from .routes.users import is_logged_in, get_active_user
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, MOTD_BANNED_TAGS,
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
) )
from .lib.babycode import babycode_to_html, EMOJI from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from datetime import datetime from .lib.exceptions import SiteNameMissingException
from .util import get_post_url, dict_to_query_string, csrf_input, get_csrf_token
from datetime import datetime, timezone
from flask_caching import Cache
import os import os
import time import time
import secrets import secrets
import hmac
import tomllib
import json
def create_default_avatar(): def create_default_avatar():
if Avatars.count() == 0: if Avatars.count() == 0:
print("Creating default avatar reference") print('Creating default avatar reference')
Avatars.create({ Avatars.create({
"file_path": "/static/avatars/default.webp", 'file_path': '/static/avatars/default.webp',
"uploaded_at": int(time.time()) 'uploaded_at': int(time.time())
}) })
def create_admin(): def create_admin():
username = "admin" username = 'admin'
if Users.count({"username": username}) == 0: if Users.count({'username': username}) == 0:
print("!!!!!Creating admin account!!!!!") print('!!!!!Creating admin account!!!!!')
password_length = 16 password_length = 16
password = secrets.token_urlsafe(password_length) password = secrets.token_urlsafe(password_length)
hashed = digest(password) hashed = digest(password)
Users.create({ Users.create({
"username": username, 'username': username,
"password_hash": hashed, 'password_hash': hashed,
"permission": PermissionLevel.ADMIN.value, '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!!!!!") 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(): def create_deleted_user():
username = "DeletedUser" username = 'DeletedUser'
if Users.count({"username": username}) == 0: if Users.count({'username': username.lower()}) == 0:
print("Creating DeletedUser") print('Creating DeletedUser')
Users.create({ Users.create({
"username": username, 'username': username.lower(),
"password_hash": "", 'display_name': username,
"permission": PermissionLevel.SYSTEM.value, 'password_hash': '',
'permission': PermissionLevel.SYSTEM.value,
}) })
def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db
from .constants import MOTD_BANNED_TAGS
post_histories_without_rss = PostHistory.findall([
('markup_language', '=', 'babycode'),
('content_rss', 'IS', None),
])
with db.transaction():
for ph in post_histories_without_rss:
ph.update({
'content_rss': babycode_to_rssxml(ph['original_markup']),
})
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
])
if len(post_histories) > 0:
print('Re-parsing user posts...')
with db.transaction():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']).result,
'content_rss': babycode_to_rssxml(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing posts done.')
users_with_sigs = Users.findall([
('signature_markup_language', '=', 'babycode'),
('signature_format_version', 'IS NOT', BABYCODE_VERSION),
('signature_original_markup', 'IS NOT', '')
])
if len(users_with_sigs) > 0:
print('Re-parsing user sigs...')
with db.transaction():
for user in users_with_sigs:
user.update({
'signature_rendered': babycode_to_html(user['signature_original_markup']).result,
'signature_format_version': BABYCODE_VERSION,
})
print(f'Re-parsed {len(users_with_sigs)} user sigs.')
stale_motds = MOTD.findall([
['markup_language', '=', 'babycode'],
['format_version', 'IS NOT', BABYCODE_VERSION]
])
if stale_motds:
print('Re-parsing MOTDs...')
with db.transaction():
for motd in stale_motds:
motd.update({
'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS).result,
'format_version': BABYCODE_VERSION,
})
print('Re-parsing MOTDs done.')
print('Re-parsing done.')
def bind_default_badges(path):
from .db import db
with db.transaction():
potential_stales = BadgeUploads.get_default()
d = os.listdir(path)
for bu in potential_stales:
if os.path.basename(bu.file_path) not in d:
print(f'Deleted stale default badge{os.path.basename(bu.file_path)}')
bu.delete()
for f in d:
real_path = os.path.join(path, f)
if not os.path.isfile(real_path):
continue
if not f.endswith('.webp'):
continue
proxied_path = f'/static/badges/{f}'
bu = BadgeUploads.find({'file_path': proxied_path})
if not bu:
BadgeUploads.create({
'file_path': proxied_path,
'uploaded_at': int(os.path.getmtime(real_path)),
})
def clear_stale_sessions():
from .db import db
with db.transaction():
now = int(time.time())
stale_sessions = Sessions.findall([
('expires_at', '<', now)
])
for sess in stale_sessions:
sess.delete()
cache = Cache()
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config['SITE_NAME'] = 'Pyrom'
app.config['DISABLE_SIGNUP'] = False
app.config['MODS_CAN_INVITE'] = True
app.config['USERS_CAN_INVITE'] = False
app.config['ADMIN_CONTACT_INFO'] = ''
app.config['GUIDE_DESCRIPTION'] = ''
if os.getenv("PYROM_PROD") is None: app.config['CACHE_TYPE'] = 'FileSystemCache'
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static") app.config['CACHE_DEFAULT_TIMEOUT'] = 300
try:
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
except FileNotFoundError:
print('No configuration file found, leaving defaults.')
if os.getenv('PYROM_PROD') is None:
app.static_folder = os.path.join(os.path.dirname(__file__), '../data/static')
app.debug = True app.debug = True
app.config["DB_PATH"] = "data/db/db.dev.sqlite" app.config['DB_PATH'] = 'data/db/db.dev.sqlite'
app.config['SERVER_NAME'] = 'localhost:8080'
load_dotenv() load_dotenv()
else: else:
app.config["DB_PATH"] = "data/db/db.prod.sqlite" app.config['DB_PATH'] = 'data/db/db.prod.sqlite'
if not app.config['SERVER_NAME']:
raise SiteNameMissingException()
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY") app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY')
app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/' app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
app.config['MAX_CONTENT_LENGTH'] = 1000 * 1000 app.config['BADGES_PATH'] = 'data/static/badges/'
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
app.config['MAX_CONTENT_LENGTH'] = 3 * 1000 * 1000 # 3M total, subject to further limits per route
os.makedirs(os.path.dirname(app.config['DB_PATH']), exist_ok = True)
os.makedirs(os.path.dirname(app.config['BADGES_UPLOAD_PATH']), exist_ok = True)
if app.config['CACHE_TYPE'] == 'FileSystemCache':
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
os.makedirs(cache_dir, exist_ok = True)
app.config['CACHE_DIR'] = cache_dir
cache.init_app(app)
from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp
from app.routes.threads import bp as threads_bp
from app.routes.users import bp as users_bp
from app.routes.guides import bp as guides_bp
from app.routes.mod import bp as mod_bp
from app.routes.posts import bp as posts_bp
app.register_blueprint(app_bp)
app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp)
app.register_blueprint(users_bp)
app.register_blueprint(guides_bp)
app.register_blueprint(mod_bp)
app.register_blueprint(posts_bp)
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
with app.app_context(): with app.app_context():
from .schema import create as create_tables from .schema import create as create_tables
from .migrations import run_migrations from .migrations import run_migrations
@@ -72,72 +222,133 @@ def create_app():
create_admin() create_admin()
create_deleted_user() create_deleted_user()
from app.routes.app import bp as app_bp clear_stale_sessions()
from app.routes.topics import bp as topics_bp
from app.routes.threads import bp as threads_bp reparse_babycode()
from app.routes.users import bp as users_bp
from app.routes.mod import bp as mod_bp bind_default_badges(app.config['BADGES_PATH'])
from app.routes.api import bp as api_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(mod_bp)
app.register_blueprint(api_bp)
app.register_blueprint(posts_bp)
app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_SECURE'] = True
@app.before_request @app.before_request
def make_session_permanent(): def revoke_session():
session.permanent = True 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'))
commit = "" @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: with open('.git/refs/heads/main') as f:
commit = f.read().strip() commit = f.read().strip()
@app.context_processor @app.context_processor
def inject_constants(): def inject_constants():
return { return {
"InfoboxIcons": InfoboxIcons, 'InfoboxHTMLClass': InfoboxHTMLClass,
"InfoboxHTMLClass": InfoboxHTMLClass, 'InfoboxKind': InfoboxKind,
"InfoboxKind": InfoboxKind, 'PermissionLevel': PermissionLevel,
"__commit": commit, '__commit': commit,
"__emoji": EMOJI, '__emoji': EMOJI,
'REACTION_EMOJI': REACTION_EMOJI,
'MOTD_BANNED_TAGS': MOTD_BANNED_TAGS,
'SIG_BANNED_TAGS': SIG_BANNED_TAGS,
} }
@app.context_processor @app.context_processor
def inject_auth(): def inject_funcs():
return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()} 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") @app.template_filter('ts_datetime')
def ts_datetime(ts, format): def ts_datetime(ts, format):
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format) return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
@app.template_filter("pluralize") @app.template_filter('dict_to_query_string')
def pluralize(number, singular = "", plural = "s"): def d2q(d):
if number == 1: return dict_to_query_string(d)
return singular
return plural @app.template_filter('pluralize')
def pluralize(subject, num=1, singular = '', plural = 's'):
if int(num) == 1:
return subject + singular
@app.template_filter("permission_string") return subject + plural
@app.template_filter('permission_string')
def permission_string(term): def permission_string(term):
return permission_level_string(term) return permission_level_string(term)
@app.template_filter('babycode') @app.template_filter('babycode')
def babycode_filter(markup): def babycode_filter(markup, nofrag=False):
return babycode_to_html(markup) return babycode_to_html(markup, fragment=not nofrag).result
@app.template_filter('extract_h2') @app.template_filter('babycode_strict')
def extract_h2(content): def babycode_strict_filter(markup, nofrag=False):
import re return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
pattern = r'<h2\s+id="([^"]+)"[^>]*>(.*?)<\/h2>'
matches = re.findall(pattern, content, re.IGNORECASE | re.DOTALL) @app.template_filter('basename_noext')
return [ def basename_noext(subj):
{'id': id_.strip(), 'text': text.strip()} return os.path.splitext(os.path.basename(subj))[0]
for id_, text in matches
] @app.errorhandler(404)
def _handle_404(e):
if request.path.startswith('/hyperapi/'):
return '<h1>not found</h1>', e.code
elif request.path.startswith('/api/'):
return {'error': 'not found'}, e.code
else:
return render_template('common/404.html'), e.code
#
# @app.errorhandler(413)
# def _handle_413(e):
# if request.path.startswith('/hyperapi/'):
# return '<h1>request body too large</h1>', e.code
# elif request.path.startswith('/api/'):
# return {'error': 'body too large'}, e.code
# else:
# return render_template('common/413.html'), e.code
# this only happens at build time but
# build time is when updates are done anyway
# sooo... /shrug
@app.template_filter('cachebust')
def cachebust(subject):
return f'{subject}?v={str(int(time.time()))}'
@app.template_filter('theme_name')
def get_theme_name(subject: str):
if subject == 'style':
return 'Default'
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
@app.template_filter('fromjson')
def fromjson(subject: str):
return json.loads(subject)
@app.template_filter('iso8601')
def unix_to_iso8601(subject: str):
return datetime.fromtimestamp(int(subject), timezone.utc).isoformat()
return app return app

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 argon2 import PasswordHasher
from functools import wraps
import secrets
import hmac
import time
import re
ph = PasswordHasher() 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): def digest(password):
return ph.hash(password) return ph.hash(password)
@@ -10,3 +28,97 @@ def verify(expected, given):
return ph.verify(expected, given) return ph.verify(expected, given)
except: except:
return False return False
def is_logged_in() -> bool:
if 'pyrom_session_key' not in session:
return False
sess = Sessions.find({'key': session['pyrom_session_key']})
if not sess:
return False
if sess.expires_at < int(time.time()):
session.clear()
sess.delete()
# flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
return False
return True
def get_active_user() -> Users | None:
if not is_logged_in():
return None
sess = Sessions.find({'key': session['pyrom_session_key']})
return Users.find({'id': sess.user_id})
def create_session(user_id, temporary=False):
expires_days = 2 if temporary else 31
return Sessions.create({
'key': secrets.token_hex(16),
'user_id': user_id,
'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60),
})
def parse_username(username: str) -> Tuple[str, str]:
"""first is the unmodified name/display name, second is username"""
if len(username) < 3:
raise ValueError
if username.lower() in FORBIDDEN_USERNAMES:
raise ValueError
invalid_regex = r'[^a-zA-Z0-9_-]'
return re.sub(invalid_regex, '_', username.lower())[:24], username
def is_password_valid(password: str) -> bool:
return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None
# annotations
def login_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
return redirect(url_for('users.log_in'))
return view_func(*args, **kwargs)
return wrapper
def mod_only(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
abort(403)
if not get_active_user().is_mod():
abort(403)
return view_func(*args, **kwargs)
return wrapper
def csrf_verified(view_func):
"""
protects a request with a form against csrf and invalidates the csrf token stored in the session.
requires @login_requred.
"""
@wraps(view_func)
def wrapper(*args, **kwargs):
if not session.get('csrf'):
abort(403)
if not request.form.get('csrf'):
abort(403)
parts = request.form['csrf'].split('.')
if len(parts) != 2:
abort(403)
given_message = parts[0]
rng = bytes.fromhex(parts[1])
session_key = session['pyrom_session_key']
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
expected = hmac.digest(current_app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256').hex()
if not hmac.compare_digest(given_message, expected):
abort(403)
session.pop('csrf')
return view_func(*args, **kwargs)
return wrapper

View File

@@ -15,6 +15,51 @@ PermissionLevelString = {
PermissionLevel.ADMIN: 'Administrator', 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): def permission_level_string(perm):
return PermissionLevelString[PermissionLevel(int(perm))] return PermissionLevelString[PermissionLevel(int(perm))]
@@ -24,16 +69,9 @@ class InfoboxKind(IntEnum):
WARN = 2 WARN = 2
ERROR = 3 ERROR = 3
InfoboxIcons = {
InfoboxKind.INFO: "/static/misc/info.svg",
InfoboxKind.LOCK: "/static/misc/lock.svg",
InfoboxKind.WARN: "/static/misc/warn.svg",
InfoboxKind.ERROR: "/static/misc/error.svg",
}
InfoboxHTMLClass = { InfoboxHTMLClass = {
InfoboxKind.INFO: "", InfoboxKind.INFO: '',
InfoboxKind.LOCK: "warn", InfoboxKind.LOCK: 'warn',
InfoboxKind.WARN: "warn", InfoboxKind.WARN: 'warn',
InfoboxKind.ERROR: "critical", InfoboxKind.ERROR: 'critical',
} }

View File

@@ -28,9 +28,10 @@ class DB:
if in_transaction: if in_transaction:
conn.commit() conn.commit()
except Exception: except Exception as e:
if in_transaction and self._connection: if in_transaction and self._connection:
conn.rollback() conn.rollback()
raise
finally: finally:
if in_transaction: if in_transaction:
self._transaction_depth -= 1 self._transaction_depth -= 1
@@ -55,7 +56,7 @@ class DB:
def insert(self, table, columns, *values): def insert(self, table, columns, *values):
if isinstance(columns, (list, tuple)): if isinstance(columns, (list, tuple)):
columns = ", ".join(columns) columns = ", ".join([f'"{column}"' for column in columns])
placeholders = ", ".join(["?"] * len(values)) placeholders = ", ".join(["?"] * len(values))
sql = f""" sql = f"""
@@ -89,6 +90,9 @@ class DB:
self.table = table self.table = table
self._where = [] # list of tuples self._where = [] # list of tuples
self._select = "*" self._select = "*"
self._group_by = ""
self._order_by = ""
self._order_asc = True
def _build_where(self): def _build_where(self):
@@ -104,6 +108,17 @@ class DB:
return " WHERE " + " AND ".join(conditions), params return " WHERE " + " AND ".join(conditions), params
def group_by(self, stmt):
self._group_by = stmt
return self
def order_by(self, stmt, asc = True):
self._order_by = stmt
self._order_asc = asc
return self
def select(self, columns = "*"): def select(self, columns = "*"):
self._select = columns self._select = columns
return self return self
@@ -112,7 +127,7 @@ class DB:
def where(self, condition, operator = "="): def where(self, condition, operator = "="):
if isinstance(condition, dict): if isinstance(condition, dict):
for key, value in condition.items(): for key, value in condition.items():
self._where.append((key, "=", value)) self._where.append((key, operator, value))
elif isinstance(condition, list): elif isinstance(condition, list):
for c in condition: for c in condition:
self._where.append(c) self._where.append(c)
@@ -122,7 +137,16 @@ class DB:
def build_select(self): def build_select(self):
sql = f"SELECT {self._select} FROM {self.table}" sql = f"SELECT {self._select} FROM {self.table}"
where_clause, params = self._build_where() where_clause, params = self._build_where()
return sql + where_clause, params
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): def build_update(self, data):
@@ -166,6 +190,13 @@ class Model:
raise AttributeError(f"No column '{key}'") raise AttributeError(f"No column '{key}'")
@classmethod
def from_data(cls, data):
instance = cls(cls.table)
instance._data = dict(data)
return instance
@classmethod @classmethod
def find(cls, condition): def find(cls, condition):
row = db.QueryBuilder(cls.table)\ row = db.QueryBuilder(cls.table)\
@@ -173,9 +204,16 @@ class Model:
.first() .first()
if not row: if not row:
return None return None
instance = cls(cls.table) return cls.from_data(row)
instance._data = dict(row)
return instance
@classmethod
def findall(cls, condition, operator='='):
rows = db.QueryBuilder(cls.table)\
.where(condition, operator)\
.all()
res = []
return [cls.from_data(row) for row in rows]
@classmethod @classmethod
@@ -187,9 +225,7 @@ class Model:
row = db.insert(cls.table, columns, *values.values()) row = db.insert(cls.table, columns, *values.values())
if row: if row:
instance = cls(cls.table) return cls.from_data(row)
instance._data = row
return instance
return None return None
@@ -207,7 +243,8 @@ class Model:
def select(cls, sel = "*"): def select(cls, sel = "*"):
qb = db.QueryBuilder(cls.table).select(sel) qb = db.QueryBuilder(cls.table).select(sel)
result = qb.all() result = qb.all()
return result if result else [] # return result if result else []
return [cls.from_data(data) for data in (result if result else [])]
def update(self, data): def update(self, data):

View File

@@ -1,35 +1,244 @@
from .babycode_parser import Parser from .babycode_parser import Parser
from markupsafe import escape 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 import re
def tag_code(children, attr): BABYCODE_VERSION = 10
is_inline = children.find('\n') == -1
if is_inline:
return f"<code class=\"inline-code\">{children}</code>" 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: else:
t = children.strip() result.append(f"<p>{para_content}</p>")
button = f"<button type=button class=\"copy-code\" value={t}>Copy</button>" current_paragraph.clear()
return f"<pre><span class=\"copy-code-container\">{button}</span><code>{t}</code></pre>"
def tag_list(children): for node in nodes:
list_body = re.sub(r" +\n", "<br>", children.strip()) if isinstance(node, str):
list_body = re.sub(r"\n\n+", "\1", list_body) paras = re.split(r'\n\n+', node)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x]) 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',
]
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>",
"img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src={attr} alt={children}></div>",
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
"code": tag_code,
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
}
def make_emoji(name, code): def make_emoji(name, code):
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">' return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
EMOJI = { EMOJI = {
'angry': make_emoji('angry', 'angry'), 'angry': make_emoji('angry', 'angry'),
@@ -49,6 +258,8 @@ EMOJI = {
'pensive': make_emoji('pensive', 'pensive'), 'pensive': make_emoji('pensive', 'pensive'),
'scissors': make_emoji('scissors', 'scissors'),
')': make_emoji('smile', ')'), ')': make_emoji('smile', ')'),
'smiletear': make_emoji('smiletear', 'smiletear'), 'smiletear': make_emoji('smiletear', 'smiletear'),
@@ -75,43 +286,325 @@ EMOJI = {
'wink': make_emoji('wink', '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"] TEXT_ONLY = ["code"]
def break_lines(text):
text = re.sub(r" +\n", "<br>", text)
text = re.sub(r"\n\n+", "<br><br>", text)
return text
def babycode_to_html(s): def tag_code(children, attr):
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n')) 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 = Parser(subj)
parser.valid_bbcode_tags = TAGS.keys() parser.valid_bbcode_tags = allowed_tags
parser.void_bbcode_tags = set(VOID_TAGS)
parser.bbcode_tags_only_text_children = TEXT_ONLY parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.mentions_allowed = '@mention' not in banned_tags
parser.valid_emotes = EMOJI.keys() parser.valid_emotes = EMOJI.keys()
elements = parser.parse() uncollapsed = parser.parse()
print(elements) elements = []
out = "" for i in range(len(uncollapsed)):
def fold(element, nobr): e = uncollapsed[i]
if isinstance(element, str): surrounding = (
if nobr: uncollapsed[i - 1] if i-1 >= 0 else None,
return element uncollapsed[i + 1] if i+1 < len(uncollapsed) else None
return break_lines(element) )
if not should_collapse(e, surrounding):
elements.append(e)
return elements
match element['type']:
case "bbcode": def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
c = "" """
for child in element['children']: transforms a string of babycode into html.
_nobr = element['name'] == "code" or element['name'] == "ul" or element['name'] == "ol"
c = c + fold(child, _nobr) parameters:
res = TAGS[element['name']](c, element['attr'])
return res s (str) - babycode string
case "link":
return f"<a href=\"{element['url']}\">{element['url']}</a>" banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
case 'emote':
return EMOJI[element['name']] fragment (bool) - skip adding an html p tag to the first element if it is inline.
case "rule": """
return "<hr>" ast = babycode_ast(s, banned_tags)
for e in elements: r = HTMLRenderer(fragment=fragment)
out = out + fold(e, False) return r.render(ast)
return out
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)

View File

@@ -4,14 +4,17 @@ import re
PAT_EMOTE = r"[^\s:]" PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w" PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\s\]]" PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]" PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
PAT_MENTION = r'[a-zA-Z0-9_-]'
class Parser: class Parser:
def __init__(self, src_str): def __init__(self, src_str):
self.valid_bbcode_tags = [] self.valid_bbcode_tags = {}
self.void_bbcode_tags = {}
self.valid_emotes = [] self.valid_emotes = []
self.bbcode_tags_only_text_children = [], self.bbcode_tags_only_text_children = []
self.mentions_allowed = True
self.source = src_str self.source = src_str
self.position = 0 self.position = 0
self.position_stack = [] self.position_stack = []
@@ -193,9 +196,10 @@ class Parser:
self.save_position() self.save_position()
# extract printable chars (extreme hack edition) # extract printable chars (extreme hack edition)
word = self.match_pattern(r'[ -~]') word = self.match_pattern(r'[!-~]')
if not re.match(PAT_LINK, word): match = re.match(PAT_LINK, word)
if not match:
self.restore_position() self.restore_position()
return None return None
@@ -205,15 +209,70 @@ class Parser:
"url": word "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): def parse_element(self, siblings):
if self.is_end_of_source(): if self.is_end_of_source():
return None return None
element = self.parse_emote() \ element = self.parse_emote() \
or self.parse_bbcode_void() \
or self.parse_bbcode() \ or self.parse_bbcode() \
or self.parse_rule() \ or self.parse_rule() \
or self.parse_link() or self.parse_link() \
or self.parse_mention()
if element is None: if element is None:
if len(siblings) > 0: if len(siblings) > 0:

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

@@ -5,9 +5,45 @@ def migrate_old_avatars():
new_path = f"/static{avatar['file_path']}" new_path = f"/static{avatar['file_path']}"
db.execute('UPDATE avatars SET file_path = ? WHERE id = ?', new_path, avatar['id']) 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] # format: [str|tuple(str, any...)|callable]
MIGRATIONS = [ MIGRATIONS = [
migrate_old_avatars, migrate_old_avatars,
'DELETE FROM sessions', # delete old lua porom sessions
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format,
create_default_bookmark_collections,
add_display_name,
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL'
] ]
def run_migrations(): def run_migrations():
@@ -17,11 +53,11 @@ def run_migrations():
) )
""") """)
if len(MIGRATIONS) == 0: if len(MIGRATIONS) == 0:
print("No migrations defined.") print('No migrations defined.')
return return
print("Running migrations...") print('Running migrations...')
ran = 0 ran = 0
completed = {int(row["id"]) for row in db.query("SELECT id FROM _migrations")} 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} to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
if not to_run: if not to_run:
print('No migrations need to run.') print('No migrations need to run.')
@@ -38,4 +74,4 @@ def run_migrations():
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id) db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
ran += 1 ran += 1
print(f"Ran {ran} migrations.") print(f'Ran {ran} migrations.')

View File

@@ -1,12 +1,13 @@
from .db import Model, db from .db import Model, db
from .constants import PermissionLevel from .constants import PermissionLevel
from flask import current_app
import time import time
class Users(Model): class Users(Model):
table = "users" table = 'users'
def get_avatar_url(self): def get_avatar_url(self):
return Avatars.find({"id": self.avatar_id}).file_path return Avatars.find({'id': self.avatar_id}).file_path
def is_default_avatar(self): def is_default_avatar(self):
return int(Avatars.find({'id': self.avatar_id}).id) == 1 return int(Avatars.find({'id': self.avatar_id}).id) == 1
@@ -17,6 +18,9 @@ class Users(Model):
def is_mod(self): def is_mod(self):
return self.permission >= PermissionLevel.MODERATOR.value return self.permission >= PermissionLevel.MODERATOR.value
def is_mod_only(self):
return self.permission == PermissionLevel.MODERATOR.value
def is_admin(self): def is_admin(self):
return self.permission == PermissionLevel.ADMIN.value return self.permission == PermissionLevel.ADMIN.value
@@ -26,29 +30,14 @@ class Users(Model):
def is_default_avatar(self): def is_default_avatar(self):
return self.avatar_id == 1 return self.avatar_id == 1
def get_latest_posts(self):
q = """SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
threads ON posts.thread_id = threads.id
JOIN
topics ON threads.topic_id = topics.id
WHERE
posts.user_id = ?
ORDER BY posts.created_at DESC
LIMIT 10"""
return db.query(q, self.id)
def get_post_stats(self): def get_post_stats(self):
q = """SELECT q = """SELECT
COUNT(DISTINCT posts.id) AS post_count, COUNT(DISTINCT posts.id) AS post_count,
COUNT(DISTINCT threads.id) AS thread_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.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 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 FROM users
LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.user_id = users.id LEFT JOIN threads ON threads.user_id = users.id
@@ -57,6 +46,7 @@ class Users(Model):
FROM threads FROM threads
GROUP BY user_id GROUP BY user_id
) latest ON latest.user_id = users.id ) latest ON latest.user_id = users.id
LEFT JOIN users AS inviter ON inviter.id = users.invited_by
WHERE users.id = ?""" WHERE users.id = ?"""
return db.fetch_one(q, self.id) return db.fetch_one(q, self.id)
@@ -83,89 +73,128 @@ class Users(Model):
return True 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): class Topics(Model):
table = "topics" table = 'topics'
@classmethod @classmethod
def get_list(_cls): def get_list(_cls):
q = """ q = """
SELECT SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked, topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username, COUNT(DISTINCT threads.id) as threads_count,
threads.title AS latest_thread_title, COUNT(posts.id) AS posts_count,
threads.slug AS latest_thread_slug, MAX(posts.created_at) as latest_post_timestamp
threads.created_at AS latest_thread_created_at
FROM FROM
topics topics
LEFT JOIN (
SELECT
*,
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
FROM
threads
) threads ON threads.topic_id = topics.id AND threads.rn = 1
LEFT JOIN LEFT JOIN
users on users.id = threads.user_id threads ON threads.topic_id = topics.id
ORDER BY LEFT JOIN
topics.sort_order ASC""" posts ON posts.thread_id = threads.id
GROUP BY topics.id ORDER BY topics.sort_order ASC"""
return db.query(q) return db.query(q)
@classmethod @classmethod
def get_active_threads(cls): 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 = """ q = """
WITH ranked_threads AS ( WITH latest_posts AS (
SELECT SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug, thread_id,
posts.id AS post_id, posts.created_at AS post_created_at, id AS latest_post_id,
users.username, user_id AS latest_post_user_id,
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn created_at AS latest_post_created_at,
FROM ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
threads FROM posts
JOIN ),
posts ON threads.id = posts.thread_id post_counts AS (
LEFT JOIN SELECT
users ON posts.user_id = users.id thread_id,
COUNT(*) AS posts_count
FROM posts
GROUP BY thread_id
) )
SELECT SELECT
topic_id, threads.id,
thread_id, thread_title, thread_slug, threads.title,
post_id, post_created_at, threads.slug,
username threads.created_at,
FROM threads.is_locked,
ranked_threads threads.is_stickied,
WHERE starter.username AS started_by,
rn = 1 starter.display_name AS started_by_display_name,
ORDER BY latest_poster.username AS latest_post_username,
topic_id""" 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 ?'
active_threads_raw = db.query(q) return db.query(q, self.id, per_page, (page - 1) * per_page)
active_threads = {}
for thread in active_threads_raw:
active_threads[int(thread['topic_id'])] = {
'thread_title': thread['thread_title'],
'thread_slug': thread['thread_slug'],
'post_id': thread['post_id'],
'username': thread['username'],
'post_created_at': thread['post_created_at']
}
return active_threads
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"
def get_threads_with_op_rss(self):
q = """ q = """
SELECT SELECT
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by, users.username AS started_by,
u.username AS latest_post_username, users.display_name AS started_by_display_name,
ph.content AS latest_post_content, ph.content_rss AS original_post_content,
posts.created_at AS latest_post_created_at, posts.id AS original_post_id
posts.id AS latest_post_id
FROM FROM
threads threads
JOIN users ON users.id = threads.user_id JOIN users ON users.id = threads.user_id
@@ -176,7 +205,7 @@ class Topics(Model):
posts.user_id, posts.user_id,
posts.created_at, posts.created_at,
posts.current_revision_id, posts.current_revision_id,
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at ASC) AS rn
FROM FROM
posts posts
) posts ON posts.thread_id = threads.id AND posts.rn = 1 ) posts ON posts.thread_id = threads.id AND posts.rn = 1
@@ -186,17 +215,24 @@ class Topics(Model):
users u ON u.id = posts.user_id users u ON u.id = posts.user_id
WHERE WHERE
threads.topic_id = ? threads.topic_id = ?
""" + order_clause + " LIMIT ? OFFSET ?" ORDER BY threads.created_at DESC"""
return db.query(q, self.id, per_page, (page - 1) * per_page) return db.query(q, self.id)
def locked(self):
return bool(self.is_locked)
class Threads(Model): class Threads(Model):
table = "threads" table = 'threads'
def get_posts(self, limit, offset): def get_posts(self, per_page, page):
q = Posts.FULL_POSTS_QUERY + " WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?" q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?'
return db.query(q, self.id, 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): def locked(self):
return bool(self.is_locked) return bool(self.is_locked)
@@ -204,10 +240,49 @@ class Threads(Model):
def stickied(self): def stickied(self):
return bool(self.is_stickied) 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): class Posts(Model):
FULL_POSTS_QUERY = """ FULL_POSTS_QUERY = """
WITH user_badges AS (
SELECT SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked 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 FROM
posts posts
JOIN JOIN
@@ -217,21 +292,70 @@ class Posts(Model):
JOIN JOIN
threads ON posts.thread_id = threads.id threads ON posts.thread_id = threads.id
LEFT JOIN LEFT JOIN
avatars ON users.avatar_id = avatars.id""" avatars ON users.avatar_id = avatars.id
LEFT JOIN
user_badges ON users.id = user_badges.user_id"""
table = "posts" 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): class PostHistory(Model):
table = "post_history" table = 'post_history'
class Sessions(Model): class Sessions(Model):
table = "sessions" table = 'sessions'
class Avatars(Model): class Avatars(Model):
table = "avatars" table = 'avatars'
class Subscriptions(Model): 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): class APIRateLimits(Model):
table = 'api_rate_limits' table = 'api_rate_limits'
@@ -256,3 +380,152 @@ class APIRateLimits(Model):
return True return True
else: else:
return False 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,43 +0,0 @@
from flask import Blueprint, request, url_for
from ..lib.babycode import babycode_to_html
from .users import is_logged_in, get_active_user
from ..models import APIRateLimits, Threads
from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/")
@bp.post('/thread-updates/<thread_id>')
def thread_updates(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
return {'error': 'no such thread'}, 404
target_time = request.json.get('since')
if not target_time:
return {'error': 'missing parameter "since"'}, 400
try:
target_time = int(target_time)
except:
return {'error': 'parameter "since" is not/cannot be converted to a number'}, 400
q = 'SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1'
new_post = db.fetch_one(q, thread_id, target_time)
if not new_post:
return {'status': 'none'}
url = url_for('threads.thread', slug=thread.slug, after=new_post['id'], _anchor=f"post-{new_post['id']}")
return {'status': 'new_post', 'url': url}
@bp.post('/babycode-preview')
def babycode_preview():
if not is_logged_in():
return {'error': 'not authorized'}, 401
user = get_active_user()
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
return {'error': 'too many requests'}, 429
markup = request.json.get('markup')
if not markup or not isinstance(markup, str):
return {'error': 'markup field missing or invalid type'}, 400
rendered = babycode_to_html(markup)
return {'html': rendered}

View File

@@ -1,12 +1,6 @@
from flask import Blueprint, redirect, url_for, render_template from flask import Blueprint, redirect, url_for, render_template
bp = Blueprint('app', __name__, url_prefix = '/')
bp = Blueprint("app", __name__, url_prefix = "/") @bp.get('/')
@bp.route("/")
def index(): def index():
return redirect(url_for("topics.all_topics")) return redirect(url_for('topics.all_topics'))
@bp.route("/babycode")
def babycode_guide():
return render_template('babycode.html')

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'

View File

@@ -1,33 +1,96 @@
from flask import ( from flask import Blueprint, abort, redirect, url_for, request, render_template
Blueprint, render_template, request, redirect, url_for from ..auth import is_logged_in, get_active_user, csrf_verified
) from ..models import Topics, Threads
from .users import login_required, mod_only bp = Blueprint('mod', __name__, url_prefix='/mod/')
from ..models import Users
from ..db import db, DB
bp = Blueprint("mod", __name__, url_prefix = "/mod/")
@bp.get("/sort-topics") @bp.before_request
@login_required def mod_only():
@mod_only("topics.all_topics") 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(): def sort_topics():
topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC") return 'stub'
return render_template("mod/sort-topics.html", topics = topics)
@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("/sort-topics") @bp.post('/topics/<int:topic_id>/edit/')
@login_required def edit_topic_post(topic_id):
@mod_only("topics.all_topics") topic = Topics.find({'id': topic_id})
def sort_topics_post(): if not topic:
with db.transaction(): abort(404)
for topic_id, new_order in request.form.items(): topic.update({
db.execute("UPDATE topics SET sort_order = ? WHERE id = ?", new_order, topic_id) 'name': request.form.get('name').strip(),
'description': request.form.get('description').strip(),
})
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
return redirect(url_for(".sort_topics")) @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.get("/user-list") @bp.post('/threads/<int:thread_id>/lock/')
@login_required def lock_thread(thread_id):
@mod_only("users.page", username = lambda: get_active_user().username) thread = Threads.find({'id': thread_id})
def user_list(): if not thread:
users = Users.select() abort(404)
return render_template("mod/user-list.html", users = users) 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'

View File

@@ -1,99 +1,44 @@
from flask import ( from flask import Blueprint, abort
Blueprint, redirect, url_for, flash, render_template, request from functools import wraps
) from ..auth import login_required, get_active_user
from .users import login_required, get_active_user from ..models import Posts
from ..lib.babycode import babycode_to_html
from ..constants import InfoboxKind
from ..db import db
from ..models import Posts, PostHistory, Threads
bp = Blueprint("posts", __name__, url_prefix = "/post") 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)
def create_post(thread_id, user_id, content, markup_language="babycode"): if post.user_id != get_active_user().id:
with db.transaction(): abort(403)
post = Posts.create({
"thread_id": thread_id,
"user_id": user_id,
"current_revision_id": None,
})
revision = PostHistory.create({ return view_func(*args, **kwargs)
"post_id": post.id, return wrapper
"content": babycode_to_html(content),
"is_initial_revision": True,
"original_markup": content,
"markup_language": markup_language,
})
post.update({"current_revision_id": revision.id}) def ownership_or_mod_required(view_func):
return post @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)
def update_post(post_id, new_content, markup_language='babycode'): return view_func(*args, **kwargs)
with db.transaction(): return wrapper
post = Posts.find({'id': post_id})
new_revision = PostHistory.create({
'post_id': post.id,
'content': babycode_to_html(new_content),
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': markup_language,
})
post.update({'current_revision_id': new_revision.id}) @bp.get('/<int:post_id>/edit/')
@bp.post("/<post_id>/delete")
@login_required
def delete(post_id):
post = Posts.find({'id': post_id})
thread = Threads.find({'id': post.thread_id})
user = get_active_user()
if user.is_mod() or post.user_id == user.id:
post.delete()
flash("Post deleted.", InfoboxKind.INFO)
return redirect(url_for('threads.thread', slug=thread.slug))
@bp.get("/<post_id>/edit")
@login_required @login_required
@ownership_required
def edit(post_id): def edit(post_id):
user = get_active_user() return 'stub'
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
editing_post = db.fetch_one(q, post_id)
if not editing_post:
return redirect(url_for('topics.all_topics'))
if editing_post['user_id'] != user.id:
return redirect(url_for('topics.all_topics'))
thread = Threads.find({'id': editing_post['thread_id']}) @bp.get('/<int:post_id>/delete/')
thread_predicate = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ?'
context_prev_q = f'{thread_predicate} AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2'
context_next_q = f'{thread_predicate} AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2'
prev_context = db.query(context_prev_q, thread.id, editing_post['created_at'])
next_context = db.query(context_next_q, thread.id, editing_post['created_at'])
return render_template('posts/edit.html',
editing_post = editing_post,
thread = thread,
prev_context = prev_context,
next_context = next_context,
)
@bp.post("/<post_id>/edit")
@login_required @login_required
def edit_form(post_id): @ownership_or_mod_required
user = get_active_user() def delete(post_id):
post = Posts.find({'id': post_id}) return 'stub'
if post.user_id != user.id:
return redirect(url_for('topics.all_topics'))
update_post(post.id, request.form.get('new_content', default=''))
thread = Threads.find({'id': post.thread_id})
return redirect(url_for('threads.thread', slug=thread.slug, after=post.id, _anchor=f'post-{post.id}'))

View File

@@ -1,225 +1,102 @@
from flask import ( from flask import Blueprint, redirect, url_for, render_template, request, abort
Blueprint, render_template, request, redirect, url_for, flash from ..auth import login_required, get_active_user
) from ..models import Threads, Posts, Topics, Users, Reactions
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions
from ..constants import InfoboxKind
from .posts import create_post
from slugify import slugify
import math import math
import time
bp = Blueprint("threads", __name__, url_prefix = "/threads/") bp = Blueprint('threads', __name__, url_prefix='/threads/')
@bp.get('/<int:thread_id>/')
@bp.get("/<slug>") def thread_by_id(thread_id):
def thread(slug): thread = Threads.find({'id': thread_id})
POSTS_PER_PAGE = 10
thread = Threads.find({"slug": slug})
if not thread: if not thread:
return redirect(url_for('topics.all_topics')) abort(404)
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
post_count = Posts.count({"thread_id": thread.id}) @bp.get('/<int:thread_id>/<slug>/')
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1) 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 page = 1
after = request.args.get("after", default=None) after = request.args.get('after')
if after is not None: if after is not None:
try:
after_id = int(after) after_id = int(after)
post_position = Posts.count([ post_position = Posts.count([
("thread_id", "=", thread.id), ('thread_id', '=', thread.id),
("id", "<=", after_id), ('id', '<=', after_id),
]) ])
page = math.ceil((post_position) / POSTS_PER_PAGE) page = math.ceil((post_position) / PER_PAGE)
except ValueError:
abort(404)
else: else:
page = max(1, min(page_count, int(request.args.get("page", default = 1)))) 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)
posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE) @bp.post('/<int:thread_id>/reply/')
topic = Topics.find({"id": thread.topic_id})
other_topics = Topics.select()
is_subscribed = False
if is_logged_in():
subscription = Subscriptions.find({
'thread_id': thread.id,
'user_id': get_active_user().id,
})
if subscription:
if int(posts[-1]['created_at']) > int(subscription.last_seen):
subscription.update({
'last_seen': int(posts[-1]['created_at'])
})
is_subscribed = True
return render_template(
"threads/thread.html",
thread = thread,
current_page = page,
page_count = page_count,
posts = posts,
topic = topic,
topics = other_topics,
is_subscribed = is_subscribed,
)
@bp.post("/<slug>")
@login_required @login_required
def reply(slug): def reply(thread_id):
thread = Threads.find({"slug": slug})
if not thread:
return redirect(url_for('topics.all_topics'))
user = get_active_user() user = get_active_user()
if user.is_guest(): thread = Threads.find({'id': thread_id})
return redirect(url_for('.thread', slug=slug)) if not thread:
abort(404)
if thread.locked() and not user.is_mod(): if thread.locked() and not user.is_mod():
return redirect(url_for('.thread', slug=slug)) # 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}'))
post_content = request.form['post_content'] @bp.get('/<int:thread_id>/feed.atom/')
post = create_post(thread.id, user.id, post_content) def feed(thread_id):
return 'stub'
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}) @bp.get('/new/')
if subscription:
subscription.update({'last_seen': int(time.time())})
elif request.form.get('subscribe', default=None) == 'on':
Subscriptions.create({'user_id': user.id, 'thread_id': thread.id, 'last_seen': int(time.time())})
return redirect(url_for(".thread", slug=slug, after=post.id, _anchor="latest-post"))
@bp.get("/create")
@login_required @login_required
def create(): def new():
all_topics = Topics.select() topics = Topics.select()
return render_template("threads/create.html", all_topics = all_topics) 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/')
@bp.post("/create")
@login_required @login_required
def create_form(): def new_post():
topic = Topics.find({"id": request.form['topic_id']}) try:
user = get_active_user() 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: if not topic:
flash('Invalid topic', InfoboxKind.ERROR) abort(404)
return redirect(url_for('.create'))
if topic.is_locked and not get_active_user().is_mod():
flash(f'Topic "{topic.name}" is locked', InfoboxKind.ERROR)
return redirect(url_for('.create'))
title = request.form['title'].strip()
now = int(time.time())
slug = f"{slugify(title)}-{now}"
post_content = request.form['initial_post']
thread = Threads.create({
"topic_id": topic.id,
"user_id": user.id,
"title": title,
"slug": slug,
"created_at": now,
})
post = create_post(thread.id, user.id, post_content)
return redirect(url_for(".thread", slug = thread.slug))
@bp.post("/<slug>/lock")
@login_required
def lock(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) if not user.can_post_to_topic(topic):
if not thread: abort(404)
return redirect(url_for('topics.all_topics'))
if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op')
thread.update({
'is_locked': target_op
})
return redirect(url_for('.thread', slug=slug))
title = request.form.get('title')
if not title:
abort(404)
@bp.post("/<slug>/sticky") if not title.strip():
@login_required abort(404)
@mod_only(".thread", slug = lambda slug: slug)
def sticky(slug):
user = get_active_user()
thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op')
thread.update({
'is_stickied': target_op
})
return redirect(url_for('.thread', slug=slug))
title = title.strip()
@bp.post("/<slug>/move") content = request.form.get('babycode_content')
@login_required
@mod_only(".thread", slug = lambda slug: slug)
def move(slug):
user = get_active_user()
new_topic_id = request.form.get('new_topic_id', default=None) thread = Threads.new(user.id, topic.id, title, content)
if new_topic_id is None: return redirect(url_for('.thread', slug=thread.slug))
flash('Thread is already in this topic.', InfoboxKind.ERROR)
return redirect(url_for('.thread', slug=slug))
new_topic = Topics.find({
'id': new_topic_id
})
if not new_topic:
return redirect(url_for('topics.all_topics'))
thread = Threads.find({
'slug': slug
})
if not thread:
return redirect(url_for('topics.all_topics'))
if new_topic.id == thread.topic_id:
flash('Thread is already in this topic.', InfoboxKind.ERROR)
return redirect(url_for('.thread', slug=slug))
old_topic = Topics.find({'id': thread.topic_id})
thread.update({'topic_id': new_topic_id})
flash(f'Topic moved from "{old_topic.name}" to "{new_topic.name}".', InfoboxKind.INFO)
return redirect(url_for('.thread', slug=slug))
@bp.post("/<slug>/subscribe")
@login_required
def subscribe(slug):
user = get_active_user()
thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
subscription = Subscriptions.find({
'user_id': user.id,
'thread_id': thread.id,
})
if request.form['subscribe'] == 'subscribe':
if subscription:
subscription.delete()
Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': int(time.time()),
})
elif request.form['subscribe'] == 'unsubscribe':
if not subscription:
return redirect(url_for('.thread', slug=slug))
subscription.delete()
elif request.form['subscribe'] == 'read':
if not subscription:
return redirect(url_for('.thread', slug=slug))
subscription.update({
'last_seen': int(time.time())
})
last_visible_post = request.form.get('last_visible_post', default=None)
if last_visible_post is not None:
return redirect(url_for('.thread', slug=thread.slug, after=last_visible_post))
else:
return redirect(url_for('users.inbox', username=user.username))

View File

@@ -1,112 +1,40 @@
from flask import ( from flask import Blueprint, redirect, url_for, render_template, request, session, abort
Blueprint, render_template, request, redirect, url_for, flash, session
) from ..models import Topics, Threads
from .users import login_required, mod_only
from ..models import Users, Topics, Threads
from ..constants import InfoboxKind
from slugify import slugify
import time
import math import math
bp = Blueprint("topics", __name__, url_prefix = "/topics/") bp = Blueprint('topics', __name__, url_prefix = '/topics/')
@bp.get('/')
@bp.get("/")
def all_topics(): def all_topics():
admin = Users.find({"id": 1}) topic_list = Topics.get_list()
return render_template("topics/topics.html", topic_list = Topics.get_list(), active_threads = Topics.get_active_threads()) 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("/create") @bp.get('/<int:topic_id>/<slug>/')
@login_required def topic(topic_id, slug):
@mod_only(".all_topics") topic = Topics.find({'id': topic_id})
def create(): if not topic:
return render_template("topics/create.html") 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'))
@bp.post("/create") PER_PAGE = 10
@login_required threads_count = Threads.count({'topic_id': topic.id})
@mod_only(".all_topics") page_count = max(1, math.ceil(threads_count / PER_PAGE))
def create_post(): try:
topic_name = request.form['name'].strip()
now = int(time.time())
slug = f"{slugify(topic_name)}-{now}"
topic_count = Topics.count()
topic = Topics.create({
"name": topic_name,
"description": request.form['description'],
"slug": slug,
"sort_order": topic_count + 1,
})
flash("Topic created.", InfoboxKind.INFO)
return redirect(url_for("topics.topic", slug = slug))
@bp.get("/<slug>")
def topic(slug):
THREADS_PER_PAGE = 10
target_topic = Topics.find({
"slug": slug
})
if not target_topic:
return redirect(url_for('.all_topics'))
threads_count = Threads.count({
"topic_id": target_topic.id
})
sort_by = session.get('sort_by', default="activity")
page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
page = max(1, min(int(request.args.get('page', default=1)), page_count)) 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)
return render_template( @bp.get('/<int:topic_id>/feed.atom/')
"topics/topic.html", def feed(topic_id):
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by), return 'stub'
topic = target_topic,
current_page = page,
page_count = page_count
)
@bp.get("/<slug>/edit")
@login_required
@mod_only(".topic", slug = lambda slug: slug)
def edit(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
return render_template("topics/edit.html", topic=topic)
@bp.post("/<slug>/edit")
@login_required
@mod_only(".topic", slug = lambda slug: slug)
def edit_post(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
topic.update({
"name": request.form.get('name', default = topic.name).strip(),
"description": request.form.get('description', default = topic.description),
"is_locked": int(request.form.get("is_locked", default = topic.is_locked)),
})
return redirect(url_for("topics.topic", slug=slug))
@bp.post("/<slug>/delete")
@login_required
@mod_only(".topic", slug = lambda slug: slug)
def delete(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
topic.delete()
flash("Topic deleted.", InfoboxKind.INFO)
return redirect(url_for("topics.all_topics"))

View File

@@ -1,516 +1,135 @@
from flask import ( from flask import Blueprint, redirect, url_for, render_template, request, session
Blueprint, render_template, request, redirect, url_for, flash, session, current_app
)
from functools import wraps from functools import wraps
from ..db import db
from ..lib.babycode import babycode_to_html
from ..models import Users, Sessions, Subscriptions, Avatars
from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify
from wand.image import Image
from wand.exceptions import WandException
from datetime import datetime, timedelta
import secrets
import time import time
import re
import os
bp = Blueprint("users", __name__, url_prefix = "/users/") 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 validate_and_create_avatar(input_image, filename): def redirect_if_logged_in(destination='topics.all_topics'):
try:
with Image(blob=input_image) as img:
img.strip()
img.gravity = 'center'
width, height = img.width, img.height
min_dim = min(width, height)
if min_dim > 256:
ratio = 256.0 / min_dim
new_width = int(width * ratio)
new_height = int(height * ratio)
img.resize(new_width, new_height)
width, height = img.width, img.height
crop_size = min(width, height)
x_offset = (width - crop_size) // 2
y_offset = (height - crop_size) // 2
img.crop(left=x_offset, top=y_offset,
width=crop_size, height=crop_size)
img.resize(256, 256)
img.format = 'webp'
img.compression_quality = 85
img.save(filename=filename)
return True
except WandException:
return False
def is_logged_in():
return "pyrom_session_key" in session
def get_active_user():
if not is_logged_in():
return None
sess = Sessions.find({"key": session["pyrom_session_key"]})
if not sess:
return None
return Users.find({"id": sess.user_id})
def create_session(user_id):
return Sessions.create({
"key": secrets.token_hex(16),
"user_id": user_id,
"expires_at": int(time.time()) + 31 * 24 * 60 * 60,
})
def extend_session(user_id):
session_obj = Sessions.find({'key': session['pyrom_session_key']})
if not session_obj:
return
new_duration = timedelta(31)
current_app.permanent_session_lifetime = new_duration
session.modified = True
session_obj.update({
'expires_at': int(time.time()) + 31 * 24 * 60 * 60
})
def validate_password(password):
pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$'
return bool(re.fullmatch(pattern, password))
def validate_username(username):
pattern = r'^[a-zA-Z0-9_-]{3,20}$'
return bool(re.fullmatch(pattern, username))
def redirect_if_logged_in(*args, **kwargs):
def decorator(view_func): def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if is_logged_in():
# resolve callables
processed_kwargs = {
k: v(**view_kwargs) if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator
def login_required(view_func):
@wraps(view_func) @wraps(view_func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not is_logged_in(): if is_logged_in():
return redirect(url_for("users.log_in")) return redirect(url_for(destination))
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapper return wrapper
def mod_only(*args, **kwargs):
def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if not get_active_user().is_mod():
# resolve callables
processed_kwargs = {
k: v(**view_kwargs) if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator return decorator
@bp.get('/log-in/')
def admin_only(*args, **kwargs): @redirect_if_logged_in()
def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if not get_active_user().is_admin():
# resolve callables
processed_kwargs = {
k: v(**view_kwargs) if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator
@bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in(): def log_in():
return render_template("users/log_in.html") return render_template('users/log_in.html')
@bp.post('/log-out/')
@bp.post("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in_post():
target_user = Users.find({
"username": request.form['username']
})
if not target_user:
flash("Incorrect username or password.", InfoboxKind.ERROR)
return redirect(url_for("users.log_in"))
if not verify(target_user.password_hash, request.form['password']):
flash("Incorrect username or password.", InfoboxKind.ERROR)
return redirect(url_for("users.log_in"))
session_obj = create_session(target_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Logged in!", InfoboxKind.INFO)
return redirect(url_for("users.log_in"))
@bp.get("/sign_up")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def sign_up():
return render_template("users/sign_up.html")
@bp.post("/sign_up")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def sign_up_post():
username = request.form['username']
password = request.form['password']
password_confirm = request.form['password-confirm']
if not validate_username(username):
flash("Invalid username.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
user_exists = Users.count({"username": username}) > 0
if user_exists:
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
if password != password_confirm:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
hashed = digest(password)
new_user = Users.create({
"username": username,
"password_hash": hashed,
"permission": PermissionLevel.GUEST.value,
})
session_obj = create_session(new_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Signed up successfully!", InfoboxKind.INFO)
return redirect(url_for("users.sign_up"))
@bp.get("/<username>")
def page(username):
target_user = Users.find({"username": username})
return render_template("users/user.html", target_user = target_user)
@bp.get("/<username>/settings")
@login_required
def settings(username):
target_user = Users.find({'username': username})
if target_user.id != get_active_user().id:
return redirect('.settings', username = get_active_user().username)
return render_template('users/settings.html')
@bp.post('/<username>/settings')
@login_required
def settings_form(username):
# we silently ignore the passed username
# and grab the correct user from the session
user = get_active_user()
topic_sort_by = request.form.get('topic_sort_by', default='activity')
if topic_sort_by == 'activity' or topic_sort_by == 'thread':
sort_by = session['sort_by'] = topic_sort_by
status = request.form.get('status', default="")[:100]
original_sig = request.form.get('signature', default='')
rendered_sig = babycode_to_html(original_sig)
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on'
user.update({
'status': status,
'signature_original_markup': original_sig,
'signature_rendered': rendered_sig,
})
flash('Settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username))
@bp.post('/<username>/set_avatar')
@login_required
def set_avatar(username):
user = get_active_user()
if user.is_guest():
flash('You must be logged in to perform this action.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
if 'avatar' not in request.files:
flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
file = request.files['avatar']
if file.filename == '':
flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
file_bytes = file.read()
now = int(time.time())
filename = f"u{user.id}d{now}.webp"
output_path = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], filename)
proxied_filename = f"/static/avatars/{filename}"
res = validate_and_create_avatar(file_bytes, output_path)
if res:
flash('Avatar updated.', InfoboxKind.INFO)
avatar = Avatars.create({
'file_path': proxied_filename,
'uploaded_at': now,
})
old_avatar = Avatars.find({'id': user.avatar_id})
user.update({'avatar_id': avatar.id})
if int(old_avatar.id) != 1:
# delete old avi, but not default
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
os.remove(filename)
old_avatar.delete()
return redirect(url_for('.settings', username=user.username))
else:
flash('Something went wrong. Please try again later.', InfoboxKind.WARN)
return redirect(url_for('.settings', user.username))
@bp.post('/<username>/change_password')
@login_required
def change_password(username):
user = get_active_user()
password = request.form.get('new_password')
password2 = request.form.get('new_password2')
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for('.settings', username=user.username))
if password != password2:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for('.settings', username=user.username))
hashed = digest(password)
user.update({'password_hash': hashed})
extend_session(user.id)
flash('Password updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username))
@bp.post('/<username>/clear_avatar')
@login_required
def clear_avatar(username):
user = get_active_user()
if user.is_default_avatar():
return redirect(url_for('.settings', user.username))
old_avatar = Avatars.find({'id': user.avatar_id})
user.update({'avatar_id': 1})
# delete old avi
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
os.remove(filename)
old_avatar.delete()
return redirect(url_for('.settings', username=user.username))
@bp.post("/log_out")
@login_required @login_required
def log_out(): def log_out():
user = get_active_user() return 'stub'
session_obj = Sessions.find({"key": session['pyrom_session_key']})
session_obj.delete()
session.clear() @bp.post('/log-in/')
return redirect(url_for(".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.post("/confirm_user/<user_id>") @bp.get('/sign-up/')
@login_required @redirect_if_logged_in()
@mod_only("topics.all_topics") def sign_up():
def confirm_user(user_id): return render_template('users/sign_up.html')
target_user = Users.find({"id": user_id})
if not target_user:
return redirect(url_for('.all_topics'))
if int(target_user.permission) > PermissionLevel.GUEST.value:
return redirect(url_for('.page', username=target_user.username))
target_user.update({ @bp.post('/sign-up/')
"permission": PermissionLevel.USER.value, @redirect_if_logged_in()
"confirmed_on": int(time.time()), 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()),
}) })
return redirect(url_for(".page", username=target_user.username))
if username_pair[0] != username_pair[1]:
@bp.post("/mod_user/<user_id>") user.update({
@login_required 'display_name': username_pair[1]
@admin_only("topics.all_topics")
def mod_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return redirect(url_for('.all_topics'))
if target_user.is_mod():
return redirect(url_for('.page', username=target_user.username))
target_user.update({
"permission": PermissionLevel.MODERATOR.value,
}) })
return redirect(url_for(".page", username=target_user.username))
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
@bp.post("/demod_user/<user_id>") return redirect(url_for('topics.all_topics'))
@login_required
@admin_only("topics.all_topics") @bp.get('/<username>/')
def demod_user(user_id): def user_page(username):
target_user = Users.find({"id": user_id}) target_user = Users.find({'username': username})
if not target_user: if not target_user:
return redirect(url_for('.all_topics')) abort(404)
if not target_user.is_mod(): return render_template('users/user_page.html', target_user=target_user)
return redirect(url_for('.page', username=target_user.username))
target_user.update({ @bp.get('/<username>/posts/')
"permission": PermissionLevel.USER.value, def posts(username):
}) return 'stub'
return redirect(url_for(".page", username=target_user.username))
@bp.get('/<username>/threads/')
def threads(username):
return 'stub'
@bp.post("/guest_user/<user_id>") @bp.get('/<username>/comments/')
@login_required def comments(username):
@admin_only("topics.all_topics") return 'stub'
def guest_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return redirect(url_for('.all_topics'))
if target_user.is_mod():
return redirect(url_for('.page', username=target_user.username))
target_user.update({ @bp.get('/<username>/settings/')
"permission": PermissionLevel.GUEST.value, def settings(username):
}) return 'stub'
return redirect(url_for(".page", username=target_user.username))
@bp.get('/<username>/inbox/')
@bp.get("/<username>/inbox")
@login_required
def inbox(username): def inbox(username):
user = get_active_user() return 'stub'
if username != user.username:
return redirect(url_for(".inbox", username = user.username))
new_posts = [] @bp.get('/<username>/bookmarks/')
subscription = Subscriptions.find({"user_id": user.id}) def bookmarks(username):
all_subscriptions = None return 'stub'
total_unreads_count = None
if subscription:
all_subscriptions = user.get_all_subscriptions()
q = """
WITH thread_metadata AS (
SELECT
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
FROM
posts
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
GROUP BY posts.thread_id
)
SELECT
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
FROM
thread_metadata tm
JOIN
posts ON posts.thread_id = tm.thread_id
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
ORDER BY
tm.newest_post_time DESC, posts.created_at ASC"""
new_posts_raw = db.query(q, user.id, user.id)
current_thread_id = None
current_thread_group = None
total_unreads_count = 0
for row in new_posts_raw:
if row['thread_id'] != current_thread_id:
current_thread_group = {
'thread_id': row['thread_id'],
'thread_title': row['thread_title'],
'unread_count': row['unread_count'],
'thread_slug': row['thread_slug'],
'newest_post_time': row['newest_post_time'],
'posts': [],
}
total_unreads_count += int(row['unread_count'])
new_posts.append(current_thread_group)
current_thread_id = row['thread_id']
current_thread_group['posts'].append({
'id': row['id'],
'created_at': row['created_at'],
'content': row['content'],
'edited_at': row['edited_at'],
'username': row['username'],
'status': row['status'],
'avatar_path': row['avatar_path'],
'thread_id': row['thread_id'],
'user_id': row['user_id'],
'original_markup': row['original_markup'],
'signature_rendered': row['signature_rendered']
})
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)

View File

@@ -76,22 +76,122 @@ SCHEMA = [
"signature_rendered" TEXT NOT NULL DEFAULT '' "signature_rendered" TEXT NOT NULL DEFAULT ''
)""", )""",
"""CREATE TABLE IF NOT EXISTS "reactions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"reaction_text" TEXT NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"key" TEXT NOT NULL UNIQUE
)""",
"""CREATE TABLE IF NOT EXISTS "invite_keys" (
"id" INTEGER NOT NULL PRIMARY KEY,
"created_by" REFERENCES users(id) ON DELETE CASCADE,
"key" TEXT NOT NULL UNIQUE
)""",
"""CREATE TABLE IF NOT EXISTS "bookmark_collections" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT FALSE,
"sort_order" INTEGER NOT NULL DEFAULT 0
)""",
"""CREATE TABLE IF NOT EXISTS "bookmarked_posts" (
"id" INTEGER NOT NULL PRIMARY KEY,
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"note" TEXT,
UNIQUE(collection_id, post_id)
)""",
"""CREATE TABLE IF NOT EXISTS "bookmarked_threads" (
"id" INTEGER NOT NULL PRIMARY KEY,
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
"thread_id" REFERENCES threads(id) ON DELETE CASCADE,
"note" TEXT,
UNIQUE(collection_id, thread_id)
)""",
"""CREATE TABLE IF NOT EXISTS "motd" (
"id" INTEGER NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"body_original_markup" TEXT NOT NULL,
"body_rendered" TEXT NOT NULL,
"markup_language" TEXT NOT NULL DEFAULT 'babycode',
"format_version" INTEGER DEFAULT NULL,
"created_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"edited_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"user_id" REFERENCES users(id) ON DELETE CASCADE
)""",
"""CREATE TABLE IF NOT EXISTS "mentions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"revision_id" REFERENCES post_history(id) ON DELETE CASCADE,
"mentioned_user_id" REFERENCES users(id) ON DELETE CASCADE,
"start_index" INTEGER NOT NULL,
"end_index" INTEGER NOT NULL,
"original_mention_text" TEXT NOT NULL
)""",
"""CREATE TABLE IF NOT EXISTS "badge_uploads" (
"id" INTEGER NOT NULL PRIMARY KEY,
"file_path" TEXT NOT NULL UNIQUE,
"uploaded_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"original_filename" TEXT,
"user_id" REFERENCES users(id) ON DELETE CASCADE
)""",
"""CREATE TABLE IF NOT EXISTS "badges" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"upload" NOT NULL REFERENCES badge_uploads(id) ON DELETE CASCADE,
"label" TEXT NOT NULL,
"link" TEXT DEFAULT '',
"sort_order" INTEGER NOT NULL DEFAULT 0
)""",
# INDEXES # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", 'CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)',
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", 'CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)',
"CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)", 'CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)',
"CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)", 'CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)',
"CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)", 'CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)',
"CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)", 'CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)',
"CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)", 'CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)',
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)", 'CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)',
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)", 'CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)',
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)", 'CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)',
'CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)',
'CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)',
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)',
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1',
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)',
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)',
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)',
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)',
'CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)',
'CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)',
'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)',
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
] ]
def create(): def create():
print("Creating schema...") print('Creating schema...')
with db.transaction(): with db.transaction():
for stmt in SCHEMA: for stmt in SCHEMA:
db.execute(stmt) db.execute(stmt)
print("Schema completed.") print('Schema completed.')

View File

@@ -1,114 +0,0 @@
<!-- kate: remove-trailing-space off; -->
{% extends 'base.html' %}
{% block title %}babycode guide{% endblock %}
{% block content %}
<div class=darkbg>
<h1 class="thread-title">Babycode guide</h1>
</div>
<div class="babycode-guide-container">
<div class="guide-topics">
{% set sections %}
<section class="babycode-guide-section">
<h2 id="what-is-babycode">What is babycode?</h2>
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
</section>
<section class="babycode-guide-section">
<h2 id="text-formatting-tags">Text formatting tags</h2>
<ul>
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
[b]Hello World[/b]<br>
Will become<br>
<strong>Hello World</strong>
</ul>
<ul>
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
[i]Hello World[/i]<br>
Will become<br>
<em>Hello World</em>
</ul>
<ul>
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
[s]Hello World[/s]<br>
Will become<br>
<del>Hello World</del>
</ul>
</section>
<section class="babycode-guide-section">
<h2 id="emoji">Emoji</h2>
<p>There are a few emoji in the style of old forum emotes:</p>
<table class="emoji-table">
<tr>
<th>Short code</th>
<th>Emoji result</th>
</tr>
{% for emoji in __emoji %}
<tr>
<td>:{{ emoji }}:</td>
<td>{{ __emoji[emoji] | safe }}</td>
</tr>
{% endfor %}
</table>
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
</section>
<section class="babycode-guide-section">
<h2 id="paragraph-rules">Paragraph rules</h2>
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
{{ '[code]paragraph 1\n\nparagraph 2[/code]' | babycode | safe }}
Will produce:<br>
{{ 'paragraph 1\n\nparagraph 2' | babycode | safe }}
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
That will produce:<br>
{{ 'paragraph 1 \nstill paragraph 1' | babycode | safe }}
</section>
<section class="babycode-guide-section">
<h2 id="links">Links</h2>
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
<a href="https://example.com">Link label</a></p>
</section>
<section class="babycode-guide-section">
<h2 id="attaching-an-image">Attaching an image</h2>
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p>
<p>Text inside the tag becomes the alt text. The attribute is the image URL.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
</section>
<section class="babycode-guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
{% set code = 'func _ready() -> void:\n\tprint("hello world!")' %}
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
{{ ('[code]%s[/code]' % code) | babycode | safe }}
<br>
<p>Inline code tags look like this: {{ '[code]Inline code[/code]' | babycode | safe }}</p>
<p>Babycodes are not parsed inside code blocks.</p>
</section>
<section class="babycode-guide-section">
<h2 id="quoting">Quoting</h2>
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
</section>
<section class="babycode-guide-section">
<h2 id="lists">Lists</h2>
{% set list = '[ul]\nitem 1\n\nitem 2\n\nitem 3 \nstill item 3 (break line without inserting a new item by using two spaces at the end of a line)\n[/ul]' %}
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
{{ ('[code]%s[/code]' % list) | babycode | safe }}
Will produce the following list:
{{ list | babycode | safe }}
</section>
{% endset %}
{{ sections | safe }}
</div>
<div class="guide-toc">
<h2>Table of contents</h2>
{% set toc = sections | extract_h2 %}
<ul>
{% for heading in toc %}
<li><a href='#{{ heading.id }}'>{{ heading.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@@ -1,29 +1,19 @@
{% from 'common/macros.html' import infobox with context %} <!DOCTYPE html>
<!DOCTYPE HTML>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
{% if self.title() %} <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Porom - {% block title %}{% endblock %}</title> <link rel="icon" type="image/png" href="/static/favicon.png">
{% else %} <link rel="stylesheet" href="{{ "/static/css/style.css" | cachebust }}">
<title>Porom</title> {% if self.title() -%}
{% endif %} <title>{{ config.SITE_NAME }} - {% block title -%}{%- endblock -%}</title>
<link rel="stylesheet" href="/static/style.css"> {%- else -%}
</head> <title>{{ config.SITE_NAME }}</title>
<body> {%- endif -%}
{% include 'common/topnav.html' %} </head>
{% with messages = get_flashed_messages(with_categories=true) %} <body>
{% if messages %} {%- include 'common/topnav.html' -%}
{% for category, message in messages %} {%- block content -%}{%- endblock -%}
{{ infobox(message, category) }} {%- include 'common/footer.html' -%}
{% endfor %} </body>
{% endif %} </html>
{% endwith %}
{% block content %}{% endblock %}
<footer class="darkbg">
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
</footer>
<script src="/static/js/copy-code.js"></script>
<script src="/static/js/ui.js"></script>
<script src="/static/js/date-fmt.js"></script>
</body>

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

@@ -1,173 +1,182 @@
{% macro pager(current_page, page_count) %} {% macro timestamp(unix_ts) -%}
{% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %}
<div class="pager">
<span>Page:</span>
{% if current_page > 5 %}
<a href="?page=1" class="pagebutton">1</a>
{% if left_start > 2 %}
<span class="currentpage">&hellip;</span>
{% endif %}
{% endif %}
{% for i in range(left_start, current_page) %}
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
{% endfor %}
{% if page_count > 0 %}
<span class="currentpage">{{current_page}}</span>
{% endif %}
{% for i in range(current_page + 1, right_end + 1) %}
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
{% endfor %}
{% if right_end < page_count %}
{% if right_end < page_count - 1 %}
<span class="currentpage">&hellip;</span>
{% endif %}
<a href="?page={{page_count}}" class="pagebutton">{{page_count}}</a>
{% endif %}
</div>
{% endmacro %}
{% macro infobox(message, kind=InfoboxKind.INFO) %}
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
<span>
<div class="infobox-icon-container">
<img src="{{ InfoboxIcons[kind] }}">
</div>
{{ message }}
</span>
</div>
{% endmacro %}
{% 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> <span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{% endmacro %} {%- endmacro %}
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="") %} {% macro subheader(title, desc='') -%}
<div class="babycode-editor-container"> <div id="subheader" class="plank secondary-bg">
<div class="tab-buttons"> <h1 class="info">{{title}}</h1>
<button type=button class="tab-button active" data-target-id="tab-edit">Write</button> {%- if desc -%}<span>{{desc}}</span>{%- endif -%}
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button> <div class="actions-group">{% if caller %}{{- caller() -}}{% endif %}</div>
</div>
<div class="tab-content active" id="tab-edit">
<span class="babycode-button-container">
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button>
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button>
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button>
</span>
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea>
<a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a>
</div>
<div class="tab-content" id="tab-preview">
<div id="babycode-preview-errors-container">Type something!</div>
<div id="babycode-preview-container"></div>
</div>
</div> </div>
<script src="/static/js/babycode-editor.js?v=2"></script> {%- endmacro %}
{% endmacro %}
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %} {% macro pager(current_page, page_count, classes='', url='', args={}) -%}
{% set save_button_text = "Post reply" if not cancel_url else "Save" %} {%- set args = dict(args.items() | rejectattr(0, 'equalto', 'page')) -%}
<form class="post-edit-form" method="post" action={{ endpoint }}> {%- if args -%}
{{babycode_editor_component(ta_name, prefill = prefill)}} {#- remove the page query argument -#}
{% if not cancel_url %} {%- set url = url + (args | dict_to_query_string) + '&page=' -%}
<span> {%- else -%}
<input type="checkbox" id="subscribe" name="subscribe" {{ "checked" if session.get('subscribe_by_default', default=true) else "" }}> {%- set url = url + '?page=' -%}
<label for="subscribe">Subscribe to thread</label> {%- endif -%}
</span> <span class="button-row {{classes}}">
{% endif %} {%- if current_page == 0 -%}
<span> {%- if page_count <= 3 -%}
<input type="submit" value="{{ save_button_text }}"> {%- for i in range(page_count) -%}
{% if cancel_url %} <a href="{{url}}{{i+1}}" class="linkbutton minimal">{{i+1}}</a>
<a class="linkbutton warn" href="{{ cancel_url }}">Cancel</a> {%- endfor -%}
{% endif %} {%- else -%}
</span> <a href="{{url}}1" class="linkbutton minimal">1</a>
</form> <a href="{{url}}2" class="linkbutton minimal">2</a>
{% endmacro %} <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 -%}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %} {%- if current_page != 1 -%}
{% set postclass = "post" %} <a href="{{url}}1" class="linkbutton minimal">1</a>
{% if editing %} {%- endif -%}
{% set postclass = postclass + " editing" %}
{% endif %} {%- if left_start > 2 -%}
<div class=" {{ postclass }}" id="post-{{ post['id'] }}"> <button class="minimal" disabled>&hellip;</button>
<div class="usercard"> {%- 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"> <div class="usercard-inner">
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;"> <img src="{{post.avatar_path}}" class="avatar">
<img src="{{ post['avatar_path'] }}" class="avatar"> <div class="usercard-rest">
</a> <a href="{{url_for('users.user_page', username=post.username)}}">{{post.display_name if post.display_name else post.username}}</a>
<a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['username'] }}</a> <abbr title="mention">@{{post.username}}</abbr>
{% if post['status'] %} <i>{{post.status}}</i>
<em class="user-status">{{ post['status'] }}</em> {%- set badges=post.badges_json | fromjson -%}
{% endif %} <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-container" {{ "id=latest-post" if is_latest else "" }}>
<div class="post-info">
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
<a href="{{ post_permalink }}" title="Permalink"><i>
{% if (post['edited_at'] | int) > (post['created_at'] | int) %}
Edited on {{ timestamp(post['edited_at']) }}
{% else %}
Posted on {{ timestamp(post['edited_at']) }}
{% endif %}
</i></a>
<span>
{% set show_edit = false %}
{% if active_user %}
{% set show_edit = (active_user.id | string) == (post['user_id'] | string) and (not post['thread_is_locked'] or active_user.is_mod()) and not no_reply %}
{% endif %}
{% if show_edit %}
<a class="linkbutton" href="{{ url_for('posts.edit', post_id=post.id, _anchor='babycode-content') }}">Edit</a>
{% endif %}
{% set show_reply = true %}
{% if active_user and post['thread_is_locked'] and not active_user.is_mod() %}
{% set show_reply = false %}
{% elif active_user and active_user.is_guest() %}
{% set show_reply = false %}
{% elif editing %}
{% set show_reply = false %}
{% elif no_reply %}
{% set show_reply = false %}
{% endif %}
{% if show_reply %}
{% set qtext = "[url=%s]%s said:[/url]" | format(post_permalink, post['username']) %}
{% set reply_text = "%s\n[quote]%s[/quote]\n" | format(qtext, post['original_markup']) %}
<button value="{{ reply_text }}" class="reply-button">Quote</button>
{% endif %}
{% set show_delete = false %}
{% if active_user %}
{% set show_delete = (((post['user_id'] | string) == (active_user.id | string) and not post['thread_is_locked']) or active_user.is_mod()) and not no_reply %}
{% endif %}
{% if show_delete %}
<button class="critical post-delete-button" value="{{ post['id'] }}">Delete</button>
{% endif %}
</span>
</div>
<div class="post-content">
{% if not editing %}
<div class="post-inner">{{ post['content'] | safe }}</div>
{% if render_sig and post['signature_rendered'] %}
<div class="signature-container">
<hr>
{{ post['signature_rendered'] | safe }}
</div>
{% endif %}
{% else %}
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
{% endif %}
</div>
</div> </div>
</div> </div>
{% endmacro %} <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

@@ -1,22 +1,26 @@
<nav id="topnav"> <nav id="header" class="plank top">
<span> <a class="site-title" href="/">Porom</a>
<a class="site-title" href="{{url_for('topics.all_topics')}}">Porom</a> <span>anti-social media</span>
</span> {%- if is_logged_in() -%}
<span> {%- with user = get_active_user() -%}
{% if not is_logged_in() %} <ul class="horizontal wrap">
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a> <li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
{% else %} <li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
{% with user = get_active_user() %} <li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox</a></li>
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a> <li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
&bullet; {% if user.is_mod() -%}
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a> <li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
&bullet; {%- endif %}
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a> </ul>
{% if user.is_mod() %} {%- endwith -%}
&bullet; {%- elif request.path != url_for('users.sign_up') and request.path != url_for('users.log_in') -%}
<a href="{{ url_for("mod.user_list") }}">User list</a> <form class="horizontal wrap" method="POST" action="{{url_for('users.log_in_post')}}">
{% endif %} <input type="hidden" name="return_to" value="{{request.path}}">
{% endwith %} <input type="text" placeholder="Username" name="username" autocomplete="username" required>
{% endif %} <input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
</span> <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> </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

@@ -1,18 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="darkbg settings-container">
<h1>Change topics order</h1>
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
<form method="post" id=topics-container>
{% for topic in topics %}
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
<div class="thread-title">{{ topic['name'] }}</div>
<div>{{ topic.description }}</div>
<input type="hidden" name="{{ topic['id'] }}" value="{{ topic['sort-order'] }}" class="topic-input">
</div>
{% endfor %}
<input type=submit value="Save order">
</form>
</div>
<script src="/static/js/sort-topics.js"></script>
{% endblock %}

View File

@@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="darkbg settings-container">
<ul>
{% for user in users %}
<li><a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}</a>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -1,18 +0,0 @@
{% from 'common/macros.html' import full_post %}
{% extends 'base.html' %}
{% block title %}editing a post{% endblock %}
{% block content %}
{% for post in prev_context | reverse %}
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
{% endfor %}
<span class="context-explain">
<span>&uarr;&uarr;&uarr;</span><i>Context</i><span>&uarr;&uarr;&uarr;</span>
</span>
{{ full_post(post=editing_post, editing=true, no_reply=true, active_user=active_user) }}
<span class="context-explain">
<span>&darr;&darr;&darr;</span><i>Context</i><span>&darr;&darr;&darr;</span>
</span>
{% for post in next_context %}
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
{% endfor %}
{% endblock %}

View File

@@ -1,22 +0,0 @@
{% from "common/macros.html" import babycode_editor_component %}
{% extends "base.html" %}
{% block title %}drafting a thread{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1>New thread</h1>
<form method="post">
<label for="topic_id">Topic</label>
<select name="topic_id" id="topic_id" autocomplete="off">
{% for topic in all_topics %}
{% set disable_topic = active_user and not active_user.can_post_to_topic(topic) %}
<option value="{{ topic['id'] }}" {{"selected" if (request.args.get('topic_id')) == (topic['id'] | string) else ""}} {{'disabled' if disable_topic else ''}} >{{ topic['name'] }}{{ ' (locked)' if topic.is_locked }}</option>
{% endfor %}
</select><br>
<label for="title">Thread title</label>
<input type="text" id="title" name="title" placeholder="Required" required>
<label for="initial_post">Post body</label><br>
{{ babycode_editor_component("initial_post") }}
<input type="submit" value="Create thread">
</form>
</div>
{% 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

@@ -1,87 +1,77 @@
{% from 'common/macros.html' import pager, babycode_editor_form, full_post %} {%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{% extends "base.html" %} {%- from 'common/macros.html' import full_post with context -%}
{% block title %}{{ thread.title }}{% endblock %} {%- extends 'base.html' -%}
{% block content %} {%- block title -%}{{thread.title}}{%- endblock -%}
{% set can_post = false %} {%- block content -%}
{% set can_lock = false %} {%- set td -%}
{% set can_subscribe = false %} <ul class="horizontal">
{% if active_user %} <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>
{% set can_subscribe = true %} {%- if thread.locked() or thread.stickied() -%}
{% set can_post = (not thread.is_locked and not active_user.is_guest()) or active_user.is_mod() %} {%- if thread.locked() -%}
{% set can_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %} <li class="visible">Locked</li>
{% endif %} {%- endif -%}
<main> {%- if thread.stickied() -%}
<nav class="darkbg"> <li class="visible">Stickied</li>
<h1 class="thread-title">{{ thread.title }}</h1> {%- endif -%}
<span>Posted in <a href="{{ url_for("topics.topic", slug=topic.slug) }}">{{ topic.name }}</a> {%- endif -%}
{% if thread.is_stickied %} </ul>
&bullet; <i>stickied, so it's probably important</i> {%- endset -%}
{% endif %} {%- call() subheader(thread.title, td) -%}
</span> <fieldset class="plank even no-shadow minimal thread-actions">
<div> <legend>Actions</legend>
{% if can_subscribe %} {%- if is_logged_in() -%}
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post"> <button>Subscribe</button>
<input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'> <button disabled title="This feature requires JavaScript to be enabled.">Bookmark&hellip;</button>
<input type='hidden' name='subscribe' value='{{ 'unsubscribe' if is_subscribed else 'subscribe' }}'> {%- endif -%}
<input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'> <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>
{% endif %} <form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
{% if can_lock %} <select name="new_topic_id" id="new-topic-id" autocomplete="off" required>
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post"> <option selected disabled value="">Move to topic:</option>
<input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}"> {%- for t in topics -%}
<input class="warn" type="submit" value="{{"Unlock thread" if thread.is_locked else "Lock thread"}}"> <option value="{{t.id}}" {{'disabled' if t.id==topic.id else ''}}>{{t.name}}</option>
</form> {%- endfor -%}
{% endif %}
{% if active_user.is_mod() %}
<form class="modform" action="{{ url_for("threads.sticky", slug=thread.slug) }}" method="post">
<input type=hidden name='target_op' value="{{ (not thread.is_stickied) | int }}">
<input class="warn" type="submit" value="{{"Unsticky thread" if thread.is_stickied else "Sticky thread"}}">
</form>
<form class="modform" action="{{ url_for("threads.move", slug=thread.slug) }}" method="post">
<label for="new_topic_id">Move to topic:</label>
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
{% for topic in topics %}
<option value="{{ topic['id'] }}" {{ "selected disabled" if (thread.topic_id | string) == (topic['id'] | string) else "" }}>{{ topic['name'] }}</option>
{% endfor %}
</select> </select>
<input class="warn" type="submit" value="Move thread"> <input type="submit" value="Move" class="warn">
</form> </form>
{% endif %} </fieldset>
</div> <fieldset class="plank even no-shadow minimal thread-actions">
</nav> <legend>Page</legend>
{% for post in posts %} {{- pager(page, page_count) -}}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length)) }} </fieldset>
{% endfor %} {%- endif -%}
{%- endcall -%}
<main>
{%- for post in posts -%}
<article id="post-{{post.id}}" class="post plank">
{{full_post(post)}}
</article>
{%- endfor -%}
</main> </main>
<div class="plank secondary-bg">
<nav id="bottomnav"> <fieldset class="plank even no-shadow minimal thread-actions">
{{ pager(current_page = current_page, page_count = page_count) }} <legend>Page</legend>
</nav> {{- pager(page, page_count) -}}
</fieldset>
{% if can_post %}
<h1>Respond to "{{ thread.title }}"</h1>
{{ babycode_editor_form(ta_name = "post_content")}}
{% endif %}
<dialog id="delete-dialog">
<div class=delete-dialog-inner>
Are you sure you want to delete the highlighted post?
<span>
<button id=post-delete-dialog-close>Cancel</button>
<button class="critical" form=post-delete-form>Delete</button>
<form id="post-delete-form" method="post"></form>
</span>
</div>
</dialog>
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
<div id="new-post-notification" class="new-concept-notification hidden">
<div class="new-notification-content">
<p>New post in thread!</p>
<span class="notification-buttons">
<button id="dismiss-new-post-button">Dismiss</button>
<a class="linkbutton" id="go-to-new-post-button">View post</a>
<button id="unsub-new-post-button">Stop updates</button>
</span>
</div>
</div> </div>
<script src="/static/js/thread.js?v=1"></script> {%- if is_logged_in() -%}
{% endblock %} <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

@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block title %}creating a topic{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1>Create topic</h1>
<form method="post">
<label for=name>Name</label>
<input type="text" name="name" id="name" required><br>
<label for="description">Description</label>
<textarea id="description" name="description" required rows=5></textarea><br>
<input type="submit" value="Create topic">
</form>
</div>
{% endblock %}

View File

@@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}creating a topic{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1>Editing topic {{ topic['name'] }}</h1>
<form method="post">
<label for=name>Name</label>
<input type="text" name="name" id="name" required value="{{ topic['name'] }}"><br>
<label for="description">Description</label>
<textarea id="description" name="description" required rows=5>{{ topic['description'] }}</textarea><br>
<input type="submit" value="Save changes">
<a class="linkbutton warn" href={{ url_for("topics.topic", slug=topic['slug'] )}}>Cancel</a><br>
<i> Note: to preserve history, you cannot change the topic URL.</i>
</form>
</div>
{% endblock %}

View File

@@ -1,79 +1,70 @@
{% from 'common/macros.html' import pager, timestamp %} {% from 'common/macros.html' import timestamp, subheader, pager %}
{% extends "base.html" %} {%- extends 'base.html' -%}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %} {%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
{% block content %} {%- block content -%}
<nav class="darkbg"> {%- set td -%}
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1> <ul class="horizontal">
<span>{{topic['description']}}</span> <li>{{topic.description}}</li>
<div> {%- if topic.locked() -%}
{% if active_user %} <li class="visible">Locked</li>
{% if not (topic['is_locked']) | int or active_user.is_mod() %} {%- endif -%}
<a class="linkbutton" href="{{ url_for("threads.create", topic_id=topic['id']) }}">New thread</a> </ul>
{% endif %} {%- endset -%}
{% if active_user.is_mod() %} {%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
<a class="linkbutton" href="{{url_for("topics.edit", slug=topic['slug'])}}">Edit topic</a> <fieldset class="plank even no-shadow minimal thread-actions">
<form class="modform" method="post" action="{{url_for("topics.edit", slug=topic['slug']) }}"> <legend>Actions</legend>
<input type="hidden" name="is_locked" value="{{ (not topic.is_locked) | int }}"> {%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%}
<input class="warn" type="submit" id="lock" value="{{"Unlock topic" if topic['is_locked'] else "Lock 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> </form>
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button> </fieldset>
{% endif %} {%- if is_mod() -%}
{% endif %} <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> </div>
</nav> <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>
{% if topic['is_locked'] %} <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>
{{ infobox("This topic is locked. Only moderators can create new threads.", InfoboxKind.INFO) }} </div>
{% endif %} {%- endfor -%}
{%- if threads | length > 0 -%}
{% if threads_list | length == 0 %} <div class="plank secondary-bg">
<p>There are no threads in this topic.</p> <fieldset class="plank even no-shadow minimal thread-actions">
{% else %} <legend>Page</legend>
{% for thread in threads_list %} {{- pager(page, page_count, args=request.args) -}}
<div class="thread"> </fieldset>
<div class="thread-sticky-container contain-svg"> </div>
{% if thread['is_stickied'] %} {%- endif -%}
<img src="/static/misc/sticky.svg"> {%- endblock -%}
<i>Stickied</i>
{% endif %}
</div>
<div class="thread-info-container">
<span>
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span>
&bullet;
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at'])}}
</span>
<span>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a>
on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>:
</span>
<span class="thread-info-post-preview">
{{ thread['latest_post_content'] | safe }}
</span>
</div>
<div class="thread-locked-container contain-svg">
{% if thread['is_locked'] %}
<img src="/static/misc/lock.svg">
<i>Locked</i>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
<nav id="bottomnav">
{{ pager(current_page = current_page, page_count = page_count) }}
</nav>
<dialog id="delete-dialog">
<div class="delete-dialog-inner">
Are you sure you want to delete this topic?
<span>
<button id=topic-delete-dialog-close>Cancel</button>
<button class="critical" form=topic-delete-form>Delete</button>
<form id="topic-delete-form" method="post" action="{{ url_for("topics.delete", slug = topic.slug) }}"></form>
</span>
</div>
</dialog>
<script src="/static/js/topic.js"></script>
{% endblock %}

View File

@@ -1,43 +1,32 @@
{% from 'common/macros.html' import timestamp %} {% from 'common/macros.html' import timestamp, subheader %}
{% extends "base.html" %} {%- extends 'base.html' -%}
{% block content %} {%- block content -%}
<nav class="darkbg"> {%- call() subheader('All topics') -%}
<h1 class="thread-title">All topics</h1> {%- if is_mod() -%}
{% if active_user and active_user.is_mod() %} <fieldset class="plank even no-shadow minimal thread-actions">
<a class="linkbutton" href={{ url_for("topics.create") }}>Create new topic</a> <legend>Moderation actions</legend>
<a class="linkbutton" href={{ url_for("mod.sort_topics") }}>Sort topics</a> <a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
{% endif %} <a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
</nav> </fieldset>
{% if topic_list | length == 0 %} {%- endif -%}
<p>There are no topics.</p> {%- endcall -%}
{% else %} {%- for topic in topics -%}
{% for topic in topic_list %} <div class="topic-info plank">
<div class="topic"> <div class="title-container">
<div class="topic-info-container"> <a class="info" href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a>
<a class="thread-title" href="{{ url_for("topics.topic", slug=topic['slug']) }}">{{ topic['name'] }}</a>
{{ topic['description'] }}
{% if topic['latest_thread_username'] %}
<span>
Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }}
</span>
{% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %}
<span>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="">{{ timestamp(thread['post_created_at']) }}</a>
</span>
{% endwith %}
{% endif %}
{% else %}
<i>No threads yet.</i>
{% endif %}
</div> </div>
<div class="topic-locked-container contain-svg"> <div>{{topic.description}}</div>
{% if topic['is_locked'] %} <ul class="horizontal">
<img src="/static/misc/lock.svg"></img> <li>{{topic.threads_count}} {{"thread" | pluralize(topic.threads_count)}}</li>
<i>Locked</i> <li>{{topic.posts_count}} {{"post" | pluralize(topic.posts_count)}}</li>
{% endif %} </ul>
<div>
{%- if topic.latest_post_timestamp -%}
Latest post at: {{timestamp(topic.latest_post_timestamp)}}
{%- else -%}
No posts yet
{%- endif -%}
</div> </div>
</div> </div>
{% endfor %} {%- endfor -%}
{% endif %} {%- endblock -%}
{% endblock %}

View File

@@ -1,51 +0,0 @@
{% from "common/macros.html" import timestamp, full_post %}
{% extends "base.html" %}
{% block title %}inbox{% endblock %}
{% block content %}
<div class="inbox-container">
{% if all_subscriptions is none %}
You have no subscriptions.<br>
{% else %}
Your subscriptions:
<ul>
{% for sub in all_subscriptions %}
<li>
<a href=" {{ url_for("threads.thread", slug=sub.thread_slug) }} ">{{ sub.thread_title }}</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = sub.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% if not new_posts %}
You have no unread posts.
{% else %}
You have {{ total_unreads_count }} unread post{{(total_unreads_count | int) | pluralize }}:
{% for thread in new_posts %}
<div class="accordion">
<div class="accordion-header">
<button type="button" class="accordion-toggle"></button>
{% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read">
</form>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</div>
<div class="accordion-content">
{% for post in thread.posts %}
{{ full_post(post, no_reply = true) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@@ -1,14 +1,22 @@
{% extends 'base.html' %} {% from 'common/macros.html' import subheader %}
{% block title %}Log in{% endblock %} {%- extends 'base.html' -%}
{% block content %} {%- block title -%}log in{%- endblock -%}
<div class="darkbg login-container"> {%- block content -%}
<h1>Log in</h1> {%- set welcome -%}
<form method="post"> Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
<label for="username">Username</label><br> {%- endset -%}
<input type="text" id="username" name="username" required autocomplete="username"><br> {{ subheader('Log in', welcome)}}
<label for="password">Password</label><br> {%- if request.args.get('error') -%}
<input type="password" id="password" name="password" required autocomplete="current-password"><br> <div class="infobox plank critical">
<input type="submit" value="Log in"> {{request.args.get('error')}}
</form>
</div> </div>
{% endblock %} {%- 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

@@ -1,39 +0,0 @@
{% from 'common/macros.html' import babycode_editor_component %}
{% extends 'base.html' %}
{% block title %}settings{% endblock %}
{% block content %}
{% set disable_avatar = not is_logged_in() %}
<div class='darkbg settings-container'>
<h1>User settings</h1>
<form class='avatar-form' method='post' action='{{ url_for('users.set_avatar', username=active_user.username) }}' enctype='multipart/form-data'>
<span>Set avatar (1mb max)</span>
<img src='{{ active_user.get_avatar_url() }}'>
<input id='file' type='file' name='avatar' accept='image/*' required>
<div>
<input type='submit' value='Upload avatar' {{ 'disabled' if disable_avatar else '' }}>
<input type='submit' value='Clear avatar' formaction='{{ url_for('users.clear_avatar', username=active_user.username) }}' formnovalidate {{ 'disabled' if active_user.is_default_avatar() else '' }}>
</div>
</form>
<form method='post'>
<label for='topic_sort_by'>Sort threads by:</label>
<select id='topic_sort_by' name='topic_sort_by'>
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>
<option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option>
</select>
<label for='status'>Status</label>
<input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'>
<label for='babycode-content'>Signature</label>
{{ babycode_editor_component(ta_name='signature', prefill=active_user.signature_original_markup, ta_placeholder='Will be shown under each of your posts', optional=true) }}
<input autocomplete='off' type='checkbox' id='subscribe_by_default' name='subscribe_by_default' {{ 'checked' if session.get('subscribe_by_default', default=true) else '' }}>
<label for='subscribe_by_default'>Subscribe to thread by default when responding</label><br>
<input type='submit' value='Save settings'>
</form>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">Change password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</div>
{% endblock %}

View File

@@ -1,17 +1,24 @@
{% extends 'base.html' %} {% from 'common/macros.html' import subheader %}
{% block title %}Sign up{% endblock %} {%- extends 'base.html' -%}
{% block content %} {%- block title -%}sign up{%- endblock -%}
<div class="darkbg login-container"> {%- block content -%}
<h1>Sign up</h1> {%- set welcome -%}
<form method="post"> Please read the rules etc. stub
<label for="username">Username</label><br> {%- endset -%}
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_-]{3,20}" title="3-20 characters. Only upper and lowercase letters, digits, hyphens, and underscores" required autocomplete="username"><br> {{ subheader('Sign up', welcome)}}
<label for="password">Password</label> {%- if request.args.get('error') -%}
<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" required autocomplete="new-password"><br> <div class="infobox plank critical">
<label for="password-confirm">Confirm Password</label> {{request.args.get('error')}}
<input type="password" id="password-confirm" name="password-confirm" 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" required autocomplete="new-password"><br>
<input type="submit" value="Sign up">
</form>
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
</div> </div>
{% endblock %} {%- 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

@@ -1,92 +0,0 @@
{% from 'common/macros.html' import timestamp %}
{% extends 'base.html' %}
{% block title %}{{ target_user.username }}'s profile{% endblock %}
{% block content %}
<div class="darkbg">
<h1 class="thread-title"><i>{{ target_user.username }}</i>'s profile</h1>
{% if active_user.id == target_user.id %}
<div class="user-actions">
<a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a>
<form method="post" action="{{ url_for("users.log_out") }}">
<input class="warn" type="submit" value="Log out">
</form>
</div>
{% if active_user.is_guest() %}
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
{% endif %}
{% endif %}
{% if active_user and active_user.is_mod() and not target_user.is_system() %}
<h1 class="thread-title">Moderation controls</h1>
{% if target_user.is_guest() %}
<p>This user is a guest. They signed up on {{ timestamp(target_user['created_at']) }}</p>
<form class="modform" method="post" action="{{ url_for("users.confirm_user", user_id=target_user.id) }}">
<input type="submit" value="Confirm user">
</form>
{% else %}
<p>This user signed up on {{ timestamp(target_user['created_at']) }} and was confirmed on {{ timestamp(target_user['confirmed_on']) }}</p>
{% if (target_user.permission | int) < (active_user.permission | int) %}
<form class="modform" method="post" action="{{ url_for("users.guest_user", user_id=target_user.id) }}">
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
</form>
{% endif %}
{% if active_user.is_admin() and not target_user.is_mod() %}
<form class="modform" method="post" action="{{ url_for("users.mod_user", user_id=target_user.id) }}">
<input class="warn" type="submit" value="Promote user to moderator">
</form>
{% elif target_user.is_mod() and (target_user.permission | int) < (active_user.permission | int) %}
<form class="modform" method="post" action="{{ url_for("users.demod_user", user_id=target_user.id) }}">
<input class="critical" type="submit" value="Demote user to regular user">
</form>
{% endif %}
{% endif %}
{% endif %}
</div>
<div class="user-info">
<div class="user-page-usercard">
<div class="usercard-inner">
<img class="avatar" src="{{ target_user.get_avatar_url() }}">
<strong class="big">{{ target_user.username }}</strong>
{% if target_user.status %}
<em class="user-status">{{ target_user.status }}</em>
{% endif %}
{% if target_user.signature_rendered %}
Signature:
<div>{{ target_user.signature_rendered | safe }}</div>
{% endif %}
</div>
</div>
<div class="user-page-stats">
{% with stats = target_user.get_post_stats() %}
<ul class="user-stats-list">
<li>Permission: {{ target_user.permission | permission_string }}</li>
<li>Posts created: {{ stats.post_count }}</li>
<li>Threads started: {{ stats.thread_count }}</li>
{% if stats.latest_thread_title %}
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
{% endif %}
</ul>
{% endwith %}
Latest posts:
{% with posts = target_user.get_latest_posts() %}
<div class="user-page-posts">
{% for post in posts %}
<div class="post-content-container">
<div class="post-info">
<a href="{{ url_for("threads.thread", slug=post.thread_slug, after=post.id) }}" title="permalink"><i>
{% if (post.edited_at | int) > (post.created_at | int) %}
Edited on {{ timestamp(post.edited_at) }}
{% else %}
Posted on {{ timestamp(post.edited_at) }}
{% endif %}
</i></a>
</div>
<div class="post-content wider user-page-post-preview">
<div class="post-inner">{{ post.content | safe }}</div>
</div>
</div>
{% endfor %}
</div>
{% endwith %}
</div>
</div>
{% 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

View File

@@ -1,172 +0,0 @@
{
let ta = document.getElementById("babycode-content");
ta.addEventListener("keydown", (e) => {
if(e.key === "Enter" && e.ctrlKey) {
// console.log(e.target.form)
if (inThread()) {
localStorage.removeItem(window.location.pathname);
}
e.target.form?.submit();
}
})
const inThread = () => {
const scheme = window.location.pathname.split("/");
return scheme[1] === "threads" && scheme[2] !== "create";
}
ta.addEventListener("input", () => {
if (!inThread()) return;
localStorage.setItem(window.location.pathname, ta.value);
})
if (inThread()) {
const form = ta.closest('.post-edit-form');
console.log(ta.closest('.post-edit-form'));
if (form){
form.addEventListener("submit", () => {
localStorage.removeItem(window.location.pathname);
})
}
}
document.addEventListener("DOMContentLoaded", () => {
if (!inThread()) return;
const prevContent = localStorage.getItem(window.location.pathname);
if (!prevContent) return;
ta.value = prevContent;
})
const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike");
const buttonUrl = document.getElementById("post-editor-url");
const buttonCode = document.getElementById("post-editor-code");
const buttonImg = document.getElementById("post-editor-img");
const buttonOl = document.getElementById("post-editor-ol");
const buttonUl = document.getElementById("post-editor-ul");
function insertTag(tagStart, newline = false, prefill = "") {
const hasAttr = tagStart[tagStart.length - 1] === "=";
let tagEnd = tagStart;
let tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
if (hasAttr) {
tagEnd = tagEnd.slice(0, -1);
}
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
const hasSelection = ta.selectionStart !== ta.selectionEnd;
const text = ta.value;
if (hasSelection) {
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
const selectionLength = realEnd - realStart;
const strStart = text.slice(0, realStart);
const strEnd = text.substring(realEnd);
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
const reconst = `${strStart}${frag}${strEnd}`;
ta.value = reconst;
if (!hasAttr){
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
} else {
ta.setSelectionRange(realStart + tagInsertEnd.length - 1, realStart + tagInsertEnd.length - 1); // cursor on attr
}
ta.focus()
} else {
if (hasAttr) {
tagInsertStart += prefill;
}
const cursor = ta.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substr(cursor);
let newCursor = strStart.length + tagInsertStart.length;
if (hasAttr) {
newCursor = cursor + tagInsertStart.length - prefill.length - 1;
}
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
ta.value = reconst;
ta.setSelectionRange(newCursor, newCursor);
ta.focus()
}
}
buttonBold.addEventListener("click", (e) => {
e.preventDefault();
insertTag("b")
})
buttonItalics.addEventListener("click", (e) => {
e.preventDefault();
insertTag("i")
})
buttonStrike.addEventListener("click", (e) => {
e.preventDefault();
insertTag("s")
})
buttonUrl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("url=", false, "link label");
})
buttonCode.addEventListener("click", (e) => {
e.preventDefault();
insertTag("code", true)
})
buttonImg.addEventListener("click", (e) => {
e.preventDefault();
insertTag("img=", false, "alt text");
})
buttonOl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("ol", true);
})
buttonUl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("ul", true);
})
const previewEndpoint = "/api/babycode-preview";
let previousMarkup = "";
const previewTab = document.getElementById("tab-preview");
previewTab.addEventListener("tab-activated", async () => {
const previewContainer = document.getElementById("babycode-preview-container");
const previewErrorsContainer = document.getElementById("babycode-preview-errors-container");
// previewErrorsContainer.textContent = "";
const markup = ta.value.trim();
if (markup === "" || markup === previousMarkup) {
return;
}
previousMarkup = markup;
const req = await fetch(previewEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({markup: markup})
})
if (!req.ok) {
switch (req.status) {
case 429:
previewErrorsContainer.textContent = "(Old preview, try again in a few seconds.)"
previousMarkup = "";
break;
case 400:
previewErrorsContainer.textContent = "(Request got malformed.)"
break;
case 401:
previewErrorsContainer.textContent = "(You are not logged in.)"
break;
default:
previewErrorsContainer.textContent = "(Error. Check console.)"
console.error(req.error);
break;
}
return;
}
const json_resp = await req.json();
previewContainer.innerHTML = json_resp.html;
previewErrorsContainer.textContent = "";
});
}

View File

@@ -1,7 +0,0 @@
for (let button of document.querySelectorAll(".copy-code")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.value)
button.textContent = "Copied!"
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
})
}

View File

@@ -1,10 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const timestampSpans = document.getElementsByClassName("timestamp");
for (let timestampSpan of timestampSpans) {
const timestamp = parseInt(timestampSpan.dataset.utc);
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
timestampSpan.textContent = date.toLocaleString();
}
}
})

View File

@@ -1,45 +0,0 @@
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
let selected = null;
let container = document.getElementById("topics-container")
function isBefore(el1, el2) {
let cur
if (el2.parentNode === el1.parentNode) {
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
if (cur === el2) return true
}
}
return false;
}
function dragOver(e) {
let target = e.target.closest(".draggable-topic")
if (!target || target === selected) {
return;
}
if (isBefore(selected, target)) {
container.insertBefore(selected, target)
} else {
container.insertBefore(selected, target.nextSibling)
}
}
function dragEnd() {
if (!selected) return;
selected.classList.remove("dragged")
selected = null;
for (let i = 0; i < container.childElementCount - 1; i++) {
let input = container.children[i].querySelector(".topic-input");
input.value = i + 1;
}
}
function dragStart(e) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', null)
selected = e.target
selected.classList.add("dragged")
}

View File

@@ -1,80 +0,0 @@
{
const ta = document.getElementById("babycode-content");
for (let button of document.querySelectorAll(".reply-button")) {
button.addEventListener("click", (e) => {
ta.value += button.value;
ta.scrollIntoView()
ta.focus();
})
}
const deleteDialog = document.getElementById("delete-dialog");
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
let deletionTargetPostContainer;
function closeDeleteDialog() {
deletionTargetPostContainer.style.removeProperty("background-color");
deleteDialog.close();
}
deleteDialogCloseButton.addEventListener("click", (e) => {
closeDeleteDialog();
})
deleteDialog.addEventListener("click", (e) => {
if (e.target === deleteDialog) {
closeDeleteDialog();
}
})
for (let button of document.querySelectorAll(".post-delete-button")) {
button.addEventListener("click", (e) => {
deleteDialog.showModal();
const postId = button.value;
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
const form = document.getElementById("post-delete-form");
form.action = `/post/${postId}/delete`
})
}
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
let now = Math.floor(new Date() / 1000);
function hideNotification() {
const notification = document.getElementById('new-post-notification');
notification.classList.add('hidden');
}
function showNewPostNotification(url) {
const notification = document.getElementById("new-post-notification");
notification.classList.remove("hidden");
document.getElementById("dismiss-new-post-button").onclick = () => {
now = Math.floor(new Date() / 1000);
hideNotification();
tryFetchUpdate();
}
document.getElementById("go-to-new-post-button").href = url;
document.getElementById("unsub-new-post-button").onclick = () => {
hideNotification();
}
}
function tryFetchUpdate() {
if (!threadEndpoint) return;
const body = JSON.stringify({'since': now});
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
.then(res => res.json())
.then(json => {
if (json.status === "none") {
setTimeout(tryFetchUpdate, 5000);
} else if (json.status === "new_post") {
showNewPostNotification(json.url);
}
})
.catch(error => console.log(error))
}
tryFetchUpdate();
}

View File

@@ -1,16 +0,0 @@
{
const deleteDialog = document.getElementById("delete-dialog");
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
deleteDialogOpenButton.addEventListener("click", (e) => {
deleteDialog.showModal();
});
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
deleteDialogCloseButton.addEventListener("click", (e) => {
deleteDialog.close();
})
deleteDialog.addEventListener("click", (e) => {
if (e.target === deleteDialog) {
deleteDialog.close();
}
})
}

View File

@@ -1,147 +0,0 @@
function activateSelfDeactivateSibs(button) {
if (button.classList.contains("active")) return;
Array.from(button.parentNode.children).forEach(s => {
if (s === button){
button.classList.add('active');
} else {
s.classList.remove('active');
}
const targetId = s.dataset.targetId;
const target = document.getElementById(targetId);
if (!target) return;
if (s.classList.contains('active')) {
target.classList.add('active');
target.dispatchEvent(new CustomEvent("tab-activated", {bubbles: false}))
} else {
target.classList.remove('active');
}
});
}
function openLightbox(post, idx) {
lightboxCurrentPost = post;
lightboxCurrentIdx = idx;
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
if (!lightboxObj.dialog.open) {
lightboxObj.dialog.showModal();
}
}
const modulo = (n, m) => ((n % m) + m) % m
function lightboxNext() {
const l = lightboxImages.get(lightboxCurrentPost).length;
const target = modulo(lightboxCurrentIdx + 1, l);
openLightbox(lightboxCurrentPost, target);
}
function lightboxPrev() {
const l = lightboxImages.get(lightboxCurrentPost).length;
const target = modulo(lightboxCurrentIdx - 1, l);
openLightbox(lightboxCurrentPost, target);
}
function constructLightbox() {
const dialog = document.createElement("dialog");
dialog.classList.add("lightbox-dialog");
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
dialog.close();
}
})
const dialogInner = document.createElement("div");
dialogInner.classList.add("lightbox-inner");
dialog.appendChild(dialogInner);
const img = document.createElement("img");
img.classList.add("lightbox-image")
dialogInner.appendChild(img);
const openOriginalAnchor = document.createElement("a")
openOriginalAnchor.text = "Open original in new window"
openOriginalAnchor.target = "_blank"
openOriginalAnchor.rel = "noopener noreferrer nofollow"
dialogInner.appendChild(openOriginalAnchor);
const navSpan = document.createElement("span");
navSpan.classList.add("lightbox-nav");
const prevButton = document.createElement("button");
prevButton.type = "button";
prevButton.textContent = "Previous";
prevButton.addEventListener("click", lightboxPrev);
const nextButton = document.createElement("button");
nextButton.type = "button";
nextButton.textContent = "Next";
nextButton.addEventListener("click", lightboxNext);
const imageCount = document.createElement("span");
imageCount.textContent = "Image of ";
navSpan.appendChild(prevButton);
navSpan.appendChild(imageCount);
navSpan.appendChild(nextButton);
dialogInner.appendChild(navSpan);
return {
img: img,
dialog: dialog,
openOriginalAnchor: openOriginalAnchor,
prevButton: prevButton,
nextButton: nextButton,
imageCount: imageCount,
}
}
let lightboxImages = new Map(); //.post-inner : Array<Object>
let lightboxObj = null;
let lightboxCurrentPost = null;
let lightboxCurrentIdx = -1;
document.addEventListener("DOMContentLoaded", () => {
// tabs
document.querySelectorAll(".tab-button").forEach(button => {
button.addEventListener("click", () => {
activateSelfDeactivateSibs(button);
});
});
// accordions
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(accordion => {
const header = accordion.querySelector(".accordion-header");
const toggleButton = header.querySelector(".accordion-toggle");
const content = accordion.querySelector(".accordion-content");
const toggle = (e) => {
e.stopPropagation();
accordion.classList.toggle("hidden");
content.classList.toggle("hidden");
toggleButton.textContent = content.classList.contains("hidden") ? "►" : "▼"
}
toggleButton.addEventListener("click", toggle);
});
//lightboxes
lightboxObj = constructLightbox();
document.body.appendChild(lightboxObj.dialog);
const postImages = document.querySelectorAll(".post-inner img.block-img");
postImages.forEach(postImage => {
const belongingTo = postImage.closest(".post-inner");
const images = lightboxImages.get(belongingTo) ?? [];
images.push({
src: postImage.src,
alt: postImage.alt,
});
const idx = images.length - 1;
lightboxImages.set(belongingTo, images);
postImage.style.cursor = "pointer";
postImage.addEventListener("click", () => {
openLightbox(belongingTo, idx);
});
});
});

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 609 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 728 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 480 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 539 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 498 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 550 B

View File

@@ -1,739 +0,0 @@
@font-face {
font-family: "site-title";
src: url("/static/fonts/ChicagoFLF.woff2");
}
@font-face {
font-family: "Cadman";
src: url("/static/fonts/Cadman_Roman.woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Cadman";
src: url("/static/fonts/Cadman_Bold.woff2");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: "Cadman";
src: url("/static/fonts/Cadman_Italic.woff2");
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: "Cadman";
src: url("/static/fonts/Cadman_BoldItalic.woff2");
font-weight: bold;
font-style: italic;
}
.tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
color: black;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
padding: 5px 20px;
margin: 10px 0;
}
body {
font-family: "Cadman";
margin: 20px 100px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
.big {
font-size: 1.8rem;
}
#topnav {
padding: 10px;
display: flex;
justify-content: end;
background-color: #c1ceb1;
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
padding: 10px;
display: flex;
justify-content: end;
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
}
.darkbg {
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
}
.user-actions {
display: flex;
column-gap: 15px;
}
.site-title {
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
}
.thread-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "usercard post-content-container";
border: 2px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.usercard {
grid-area: usercard;
padding: 20px 10px;
border: 4px outset rgb(217.26, 220.38, 213.42);
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
border-right: solid 2px;
}
.usercard-inner {
display: flex;
flex-direction: column;
align-items: center;
top: 10px;
position: sticky;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content";
grid-area: post-content-container;
}
.post-info {
grid-area: post-info;
display: flex;
justify-content: space-between;
padding: 5px 20px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.post-content {
grid-area: post-content;
padding: 20px;
margin-right: 25%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.post-content.wider {
margin-right: 12.5%;
}
.post-inner {
height: 100%;
}
pre code {
display: block;
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid rgb(229.84, 231.92, 227.28);
padding: 20px;
overflow: scroll;
tab-size: 4;
}
.inline-code {
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: #c1ceb1;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-code-container {
position: sticky;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: "Cadman";
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: #c1ceb1;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
}
.copy-code-container::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
.copy-code {
margin-right: 10px;
}
blockquote {
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
border-left: 10px solid rgb(229.84, 231.92, 227.28);
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.user-info {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-template-areas: "user-page-usercard user-page-stats";
}
.user-page-usercard {
grid-area: user-page-usercard;
padding: 20px 10px;
border: 4px outset rgb(217.26, 220.38, 213.42);
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
border-right: solid 2px;
}
.user-page-stats {
grid-area: user-page-stats;
padding: 20px 30px;
border: 1px solid black;
}
.user-stats-list {
list-style: none;
margin: 0 0 10px 0;
}
.user-page-posts {
border-left: solid 1px black;
border-right: solid 1px black;
border-bottom: solid 1px black;
background-color: #c1ceb1;
}
.user-page-post-preview {
max-height: 200px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
margin-bottom: 10px;
}
.username-link {
overflow-wrap: anywhere;
}
.user-status {
text-align: center;
}
button, input[type=submit], .linkbutton {
display: inline-block;
background-color: rgb(177, 206, 204.5);
}
button:hover, input[type=submit]:hover, .linkbutton:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
button:active, input[type=submit]:active, .linkbutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
button.critical, input[type=submit].critical, .linkbutton.critical {
color: white;
background-color: red;
}
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
background-color: #ff3333;
}
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
background-color: rgb(149.175, 80.325, 80.325);
}
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
background-color: rgb(174.675, 156.825, 156.825);
}
button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d;
}
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
background-color: rgb(251.8, 251.8, 163.8);
}
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
background-color: rgb(198.3813559322, 198.3813559322, 154.4186440678);
}
button.warn:disabled, input[type=submit].warn:disabled, .linkbutton.warn:disabled {
background-color: rgb(217.55, 217.55, 209.85);
}
input[type=file]::file-selector-button {
background-color: rgb(177, 206, 204.5);
margin: 10px 10px;
}
input[type=file]::file-selector-button:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
input[type=file]::file-selector-button:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
input[type=file]::file-selector-button:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
p {
margin: 15px 0;
}
.pagebutton {
background-color: rgb(177, 206, 204.5);
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.pagebutton:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
.pagebutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
.pagebutton:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
.currentpage {
border: none;
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.modform {
display: inline;
}
.login-container > * {
width: 25%;
margin: auto;
}
.settings-container > * {
width: 40%;
margin: auto;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
input[type=text], input[type=password], textarea, select {
border: 1px solid black;
border-radius: 3px;
padding: 7px 10px;
width: 100%;
box-sizing: border-box;
resize: vertical;
background-color: rgb(217.8, 225.6, 208.2);
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(230.2, 235.4, 223.8);
}
.infobox {
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
}
.infobox.critical {
background-color: rgb(237, 129, 129);
}
.infobox.warn {
background-color: #fbfb8d;
}
.infobox > span {
display: flex;
align-items: center;
}
.infobox-icon-container {
min-width: 60px;
padding-right: 15px;
}
.thread {
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
}
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
}
.block-img {
object-fit: contain;
max-width: 400px;
max-height: 400px;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: #c1ceb1;
padding: 5px 20px;
border-top: 1px solid black;
border-bottom: 1px solid black;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 110px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
margin-right: 25%;
}
.babycode-guide-section {
background-color: #c1ceb1;
padding: 5px 20px;
border: 1px solid black;
padding-right: 25%;
}
.babycode-guide-container {
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas: "guide-topics guide-toc";
}
.guide-topics {
grid-area: guide-topics;
overflow: hidden;
}
.guide-toc {
grid-area: guide-toc;
position: sticky;
top: 100px;
align-self: start;
padding: 10px;
border-bottom-right-radius: 8px;
background-color: rgb(177, 206, 204.5);
border-right: 1px solid black;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.emoji-table tr td {
text-align: center;
}
.emoji-table tr th {
padding-left: 50px;
padding-right: 50px;
}
.emoji-table {
margin: auto;
}
.emoji-table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas: "topic-info-container topic-locked-container";
}
.topic-info-container {
grid-area: topic-info-container;
background-color: #c1ceb1;
padding: 5px 20px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
margin: 12px 0;
border-top: 6px outset rgb(217.26, 220.38, 213.42);
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.draggable-topic.dragged {
background-color: rgb(177, 206, 204.5);
}
.editing {
background-color: rgb(217.26, 220.38, 213.42);
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
font-size: 1rem;
}
.babycode-editor-container {
width: 100%;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
}
.tab-button {
background-color: rgb(177, 206, 204.5);
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.tab-button:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
.tab-button:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
.tab-button:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
.tab-button.active {
background-color: #beb1ce;
padding-top: 8px;
}
.tab-content {
display: none;
}
.tab-content.active {
min-height: 250px;
display: block;
background-color: rgb(191.3137931034, 189.7, 193.3);
border: 1px solid black;
padding: 10px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
ul, ol {
margin: 10px 0 10px 30px;
padding: 0;
}
.new-concept-notification.hidden {
display: none;
}
.new-concept-notification {
position: fixed;
bottom: 80px;
right: 80px;
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.emoji {
max-width: 15px;
max-height: 15px;
}
.accordion {
border-top-right-radius: 3px;
border-top-left-radius: 3px;
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
background-color: rgb(159.0271653543, 162.0727712915, 172.9728346457);
padding: 0 10px;
gap: 10px;
border-bottom: 1px solid black;
}
.accordion-toggle {
padding: 0;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.accordion-title {
margin-right: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accordion-content {
padding: 0 15px;
}
.accordion-content.hidden {
display: none;
}
.inbox-container {
padding: 10px;
}
.babycode-button-container {
display: flex;
gap: 10px;
}
.babycode-button {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}

View File

@@ -1,14 +1,16 @@
argon2-cffi==25.1.0 argon2-cffi==25.1.0
argon2-cffi-bindings==21.2.0 argon2-cffi-bindings==21.2.0
blinker==1.9.0 blinker==1.9.0
cachelib==0.13.0
cffi==1.17.1 cffi==1.17.1
click==8.2.1 click==8.2.1
dotenv==0.9.9
Flask==3.1.1 Flask==3.1.1
Flask-Caching==2.3.1
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.2 MarkupSafe==3.0.2
pycparser==2.22 pycparser==2.22
Pygments==2.19.2
python-dotenv==1.1.1 python-dotenv==1.1.1
python-slugify==8.0.4 python-slugify==8.0.4
text-unidecode==1.3 text-unidecode==1.3

View File

@@ -1,748 +0,0 @@
@use "sass:color";
@font-face {
font-family: "site-title";
src: url("/static/fonts/ChicagoFLF.woff2");
}
@mixin cadman($var) {
font-family: "Cadman";
src: url("/static/fonts/Cadman_#{$var}.woff2");
}
@font-face {
@include cadman("Roman");
font-weight: normal;
font-style: normal;
}
@font-face {
@include cadman("Bold");
font-weight: bold;
font-style: normal;
}
@font-face {
@include cadman("Italic");
font-weight: normal;
font-style: italic;
}
@font-face {
@include cadman("BoldItalic");
font-weight: bold;
font-style: italic;
}
$accent_color: #c1ceb1;
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
$button_color: color.adjust($accent_color, $hue: 90);
$button_color2: color.adjust($accent_color, $hue: 180);
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
%button-base {
cursor: default;
color: black;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
padding: 5px 20px;
margin: 10px 0;
}
@mixin button($color) {
@extend %button-base;
background-color: $color;
&:hover {
background-color: color.scale($color, $lightness: 20%);
}
&:active {
background-color: color.scale($color, $lightness: -10%, $saturation: -70%);
}
&:disabled {
background-color: color.scale($color, $lightness: 30%, $saturation: -90%);
}
}
@mixin navbar($color) {
padding: 10px;
display: flex;
justify-content: end;
background-color: $color;
}
body {
font-family: "Cadman";
// font-size: 18px;
margin: 20px 100px;
background-color: $main_bg;
}
.big {
font-size: 1.8rem;
}
#topnav {
@include navbar($accent_color);
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
@include navbar($dark_bg);
}
.darkbg {
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
background-color: $dark_bg;
}
.user-actions {
display: flex;
column-gap: 15px;
}
.site-title {
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
}
.thread-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas:
"usercard post-content-container";
border: 2px outset $dark2;
}
.usercard {
grid-area: usercard;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.usercard-inner {
display: flex;
flex-direction: column;
align-items: center;
top: 10px;
position: sticky;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"post-info"
"post-content";
grid-area: post-content-container;
}
.post-info {
grid-area: post-info;
display: flex;
justify-content: space-between;
padding: 5px 20px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.post-content {
grid-area: post-content;
padding: 20px;
margin-right: 25%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.post-content.wider {
margin-right: 12.5%;
}
.post-inner {
height: 100%;
}
pre code {
display: block;
background-color: $verydark;
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid $lighter;
padding: 20px;
overflow: scroll;
tab-size: 4;
}
.inline-code {
background-color: $verydark;
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: $accent_color;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-code-container {
position: sticky;
// width: 100%;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: "Cadman";
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: $accent_color;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
&::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
}
.copy-code {
margin-right: 10px;
}
blockquote {
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
border-left: 10px solid $lighter;
background-color: $dark2;
}
.user-info {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-template-areas:
"user-page-usercard user-page-stats";
}
.user-page-usercard {
grid-area: user-page-usercard;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.user-page-stats {
grid-area: user-page-stats;
padding: 20px 30px;
border: 1px solid black;
}
.user-stats-list {
list-style: none;
margin: 0 0 10px 0;
}
.user-page-posts {
border-left: solid 1px black;
border-right: solid 1px black;
border-bottom: solid 1px black;
background-color: $accent_color;
}
.user-page-post-preview {
max-height: 200px;
mask-image: linear-gradient(180deg,#000 60%,transparent);
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
margin-bottom: 10px;
}
.username-link {
overflow-wrap: anywhere;
}
.user-status {
text-align: center;
}
button, input[type="submit"], .linkbutton {
display: inline-block;
@include button($button_color);
&.critical {
color: white;
@include button(red);
}
&.warn {
@include button(#fbfb8d);
}
}
// not sure why this one has to be separate, but if it's included in the rule above everything breaks
input[type="file"]::file-selector-button {
@include button($button_color);
margin: 10px 10px;
}
p {
margin: 15px 0;
}
.pagebutton {
@include button($button_color);
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.currentpage {
@extend %button-base;
border: none;
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.modform {
display: inline;
}
.login-container > * {
width: 25%;
margin: auto;
}
.settings-container > * {
width: 40%;
margin: auto;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
input[type="text"], input[type="password"], textarea, select {
border: 1px solid black;
border-radius: 3px;
padding: 7px 10px;
width: 100%;
box-sizing: border-box;
resize: vertical;
background-color: color.scale($accent_color, $lightness: 40%);
&:focus {
background-color: color.scale($accent_color, $lightness: 60%);
}
}
.infobox {
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
&.critical {
background-color: rgb(237, 129, 129);
}
&.warn {
background-color: #fbfb8d;
}
}
.infobox > span {
display: flex;
align-items: center;
}
.infobox-icon-container {
min-width: 60px;
padding-right: 15px;
}
.thread {
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas:
"thread-sticky-container thread-info-container thread-locked-container";
}
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 2px outset $light;
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset $light;
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
&:not(.full) > svg, &:not(.full) > img {
height: 50%;
width: 50%;
}
&.full > svg, &.full > img {
height: 100%;
width: 100%;
}
}
.block-img {
object-fit: contain;
max-width: 400px;
max-height: 400px;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: $accent_color;
padding: 5px 20px;
border-top: 1px solid black;
border-bottom: 1px solid black;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 110px;
mask-image: linear-gradient(180deg,#000 60%,transparent);
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
margin-right: 25%;
}
.babycode-guide-section {
background-color: $accent_color;
padding: 5px 20px;
border: 1px solid black;
padding-right: 25%;
}
.babycode-guide-container {
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"guide-topics guide-toc";
}
.guide-topics {
grid-area: guide-topics;
overflow: hidden;
}
.guide-toc {
grid-area: guide-toc;
position: sticky;
top: 100px;
align-self: start;
padding: 10px;
// border-top-right-radius: 16px;
border-bottom-right-radius: 8px;
background-color: $button_color;
border-right: 1px solid black;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.emoji-table tr td {
text-align: center;
}
.emoji-table tr th {
padding-left: 50px;
padding-right: 50px;
}
.emoji-table {
margin: auto;
}
.emoji-table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"topic-info-container topic-locked-container";
}
.topic-info-container {
grid-area: topic-info-container;
background-color: $accent_color;
padding: 5px 20px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset $light;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: $accent_color;
padding: 20px;
margin: 12px 0;
border-top: 6px outset $light;
border-bottom: 6px outset $dark2;
&.dragged {
background-color: $button_color;
}
}
.editing {
background-color: $light;
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
font-size: 1rem;
}
.babycode-editor-container {
width: 100%;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
}
.tab-button {
@include button($button_color);
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
&.active {
background-color: $button_color2;
padding-top: 8px;
}
}
.tab-content {
display: none;
&.active {
min-height: 250px;
display: block;
background-color: color.adjust($button_color2, $saturation: -20%);
border: 1px solid black;
padding: 10px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
}
ul, ol {
margin: 10px 0 10px 30px;
padding: 0;
}
.new-concept-notification.hidden {
display: none;
}
.new-concept-notification {
position: fixed;
bottom: 80px;
right: 80px;
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.emoji {
max-width: 15px;
max-height: 15px;
}
.accordion {
border-top-right-radius: 3px;
border-top-left-radius: 3px;
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
background-color: $accordion_color;
padding: 0 10px;
gap: 10px;
border-bottom: 1px solid black;
}
.accordion-toggle {
padding: 0;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.accordion-title {
margin-right: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accordion-content {
padding: 0 15px;
}
.accordion-content.hidden {
display: none;
}
.inbox-container {
padding: 10px;
}
.babycode-button-container {
display: flex;
gap: 10px;
}
.babycode-button {
padding: 5px 10px;
min-width: 36px;
&> * {
font-size: 1rem;
}
}

View File

@@ -8,7 +8,11 @@ chmod-socket = 666
plugins = python3 plugins = python3
virtualenv = /opt/venv virtualenv = /opt/venv
pythonpath = /opt/venv/lib/python3.11/site-packages pythonpath = /opt/venv/lib/python3.13/site-packages
uid = www-data uid = www-data
gid = www-data gid = www-data
env = LANG=C.UTF-8
env = LANGUAGE=C.UTF-8
env = LC_ALL=C.UTF-8