Compare commits

...

127 Commits

Author SHA1 Message Date
8723ce88dc mention pyrom move 2025-08-01 00:10:19 +00:00
92548e6bad pin image version in dockerfile 2025-06-20 16:31:30 +03:00
93634f230f cachebust ui.js 2025-06-12 16:04:33 +03:00
e0de885cdd add lightbox for post image previews 2025-06-12 15:58:10 +03:00
ccccb9d238 fix alt text not being passed correctly in babycode 2025-06-12 13:10:03 +03:00
71f795bae5 trim Dockerfile now that upstream luarocks is fixed and alpine upgraded luarocks 2025-06-07 03:45:55 +03:00
973274fed3 save typed reply to localStorage in thread 2025-06-06 18:48:47 +03:00
502f1c59de add more tag buttons to babycode editor 2025-06-06 18:22:53 +03:00
0c820183a6 new user page 2025-06-05 11:02:59 +03:00
cfb676a453 show subscribed threads in inbox even when there's no unreads 2025-06-05 05:37:51 +03:00
2bf5f4faa3 make usercard sticky on post 2025-06-05 00:37:05 +03:00
3b7a7db0ca add latest post info to topics view 2025-06-03 05:56:28 +03:00
79d84394c0 use timestamp component in inbox 2025-06-03 05:47:11 +03:00
e45fed69bb >_> 2025-06-02 23:26:33 +03:00
51eadc20ec on post create, update subscription if subscribed 2025-06-02 23:12:29 +03:00
303e032673 do not create duplicate subscriptions 2025-06-02 23:11:12 +03:00
22526c953e add an inbox view 2025-06-02 23:05:28 +03:00
bd1ba6c087 add subscribing and unsubscribing to threads 2025-06-02 20:54:36 +03:00
1e23959e52 add accordion support 2025-06-02 17:54:40 +03:00
68d109f428 drop the SSE, use client side fetch every 5s for thread updates 2025-06-01 22:30:00 +03:00
b56ab2522c sort threads in topic by activity by default (bump) and add setting 2025-06-01 14:04:45 +03:00
24d6d7cebf add settings shortcut to topnav 2025-06-01 14:04:18 +03:00
0020902737 minor grammar fix in babycode.etlua 2025-06-01 12:03:40 +03:00
d227932878 add a proper babycode help page 2025-06-01 10:53:37 +03:00
db8d32113c add some new emoji 2025-06-01 07:53:53 +03:00
f61b618f1e clarify line breaking rules 2025-06-01 07:12:14 +03:00
615cd36eab add previews to babycode editor component 2025-06-01 02:35:55 +03:00
8a00500387 add api endpoint to preview babycode 2025-06-01 00:45:11 +03:00
72709226c0 change reply button to quote and simplify quote markup; hashlink to edit box when editing 2025-05-31 21:13:57 +03:00
eb9cadd36d focus textarea when replying 2025-05-31 21:01:20 +03:00
46d125fa18 submit post on ctrl+enter 2025-05-31 07:12:42 +03:00
9e786893b3 list deps directly in dockerfile for now 2025-05-31 06:57:57 +03:00
1a37ccfd86 add babycode parser, courtesy of kaesa 2025-05-30 22:59:21 +03:00
3e9f771ad3 fix emojis being beeg in user view 2025-05-30 17:29:36 +03:00
bf2bcc4a7f use direct url for sqlite 2025-05-30 17:07:29 +03:00
dacc5a8d7b add some forumoji 2025-05-30 05:24:14 +03:00
bda68ed7f4 add a max height to threads in topic view 2025-05-28 19:25:14 +03:00
cf66336e78 add topic moving option for mods 2025-05-28 19:07:05 +03:00
8e646666d1 delete session cookie when logging out and deleting account 2025-05-28 14:39:36 +03:00
aa49d8e4b9 let users change their password william nilliam 2025-05-28 04:43:49 +03:00
1e5e2a2c27 show previous context in reversed order in edit post view 2025-05-28 04:15:34 +03:00
1a96612544 add notification for new post in thread 2025-05-28 04:01:51 +03:00
8ea9afd39d show timestamps in local time 2025-05-28 00:16:37 +03:00
873a4c0c15 set session cookie with expiration date and secure flag 2025-05-27 18:57:20 +03:00
90cacad449 remove print from config when reading commit hash 2025-05-27 17:45:57 +03:00
d1e29822ac allow mods to edit their post even if thread is locked 2025-05-27 17:28:39 +03:00
8cd4695794 add img to babycode 2025-05-27 17:20:55 +03:00
c79cc5797a show running commit in footer 2025-05-27 16:10:35 +03:00
d44c1156b7 add overflow to post preview in topic view 2025-05-27 16:04:56 +03:00
1087e0d511 add overflow to post content 2025-05-26 23:04:30 +03:00
e46883c3c1 add lists support to babycode 2025-05-26 19:45:47 +03:00
ea83a31b16 make cancel post edit button link to the post in question 2025-05-26 04:07:50 +03:00
94f58fef73 fix nested quotes and alter linebreak parsing 2025-05-26 03:32:54 +03:00
6eee661b58 fix reply button not working now 2025-05-26 02:40:09 +03:00
07a65e9633 fix babycode editor buttons not working in user settings 2025-05-26 02:28:22 +03:00
a2d3672fa8 properly handle url= tags 2025-05-26 02:28:22 +03:00
1e9809e4b2 fix: specify docker compose version 2025-05-25 20:39:12 +02:00
9f6541c90c chore: ensure docker compose acts like a service 2025-05-25 20:33:13 +02:00
c426c8aa2a add font and acknowledgements 2025-05-25 21:07:21 +03:00
a4a79d964e change regular infobox color to blue 2025-05-25 06:36:47 +03:00
025b3063a6 allow mods to delete topics 2025-05-25 06:36:31 +03:00
5e7dec08b9 allow users to lock and sticky threads 2025-05-25 06:07:42 +03:00
95e4384f22 mention the need for interactive shell in docker instructions 2025-05-25 05:31:18 +03:00
82fb724770 fix avatars 2025-05-25 05:21:35 +03:00
ca0256268b add ability to delete posts 2025-05-25 04:29:15 +03:00
8a9a5e5bd9 add titles to more pages 2025-05-24 17:39:23 +03:00
ccb2819b01 add user signatures 2025-05-24 17:28:07 +03:00
fbe582ccbc minor formatting fix 2025-05-24 16:48:38 +03:00
22f97dcc82 add quote to babycode, improve quote text 2025-05-24 16:45:34 +03:00
2773ba5243 remove debug endpoint 2025-05-24 16:16:49 +03:00
2a22f6d2ce even stronger cachebusting 2025-05-24 16:15:37 +03:00
ed34f394ce add inline code block support 2025-05-24 16:15:25 +03:00
11dbec0793 better babycode parsing, add horizontal rule 2025-05-24 15:47:31 +03:00
69bfaa8db0 add code blocks + copy 2025-05-24 05:12:46 +03:00
66318698e5 add reusable babycode editor 2025-05-24 02:45:54 +03:00
ec3f144b4e add reply button functionality 2025-05-24 01:07:58 +03:00
e7260090ac add post editing 2025-05-24 00:11:27 +03:00
738b4163a8 prepare post history 2025-05-23 21:29:22 +03:00
3dde2ba49a migrate before running server 2025-05-23 21:28:14 +03:00
12269dd9b3 add sorting topics view 2025-05-23 20:41:06 +03:00
800cd6a1bf alias js folder to /static/js in ngx config 2025-05-23 20:40:32 +03:00
f3aaa6d24d fix wrong url redirect on thread create 2025-05-23 16:12:09 +03:00
f071919fa8 better? cache busting 2025-05-23 16:08:26 +03:00
d70b27cda0 use infobox to signal topic locked 2025-05-23 15:28:09 +03:00
1038e8ea1e add .touched files to ignores 2025-05-23 14:13:37 +03:00
17e231ed74 revise instructions 2025-05-23 13:35:54 +03:00
7f17d4c29e finalize docker setup
now fully works via docker \o/
2025-05-23 13:14:51 +03:00
4fa80aa8c7 VERY quick fix: randomize seed when making admin account 2025-05-23 05:41:38 +03:00
2ccacf12a3 use correct check in start script 2025-05-23 04:55:39 +03:00
0d7ed52679 NOT DONE YET - allow containerization
a bunch was restructured to make it amenable to docker.

it works fine, except when writing to the db. trying to log in (thus creating a session)
will have Lapis throw "attempt to write a readonly database"
2025-05-23 04:46:10 +03:00
af20b626d5 put db and static into data/ 2025-05-22 23:20:15 +03:00
ddad153875 argon2 experiment 2025-05-22 22:30:20 +03:00
74a0ae5027 fix infobox in user view 2025-05-22 22:30:14 +03:00
d4ab245297 set the avatar to default FIRST when clearing avatar 2025-05-22 11:58:05 +03:00
a28572003e add quick and dirty user list for mods 2025-05-22 04:00:11 +03:00
511687c8c3 add proper instructions 2025-05-22 03:36:56 +03:00
7d761bae2e actually delete the avatar row when deleting avatar file 2025-05-22 03:02:27 +03:00
7f10dde1ea add a sort order to topics for the future 2025-05-22 02:57:25 +03:00
9438d3704b make default avatar use the avatars table 2025-05-22 02:44:24 +03:00
16127983ab add markup to topics create 2025-05-22 01:57:15 +03:00
1cb9262ad7 add markup to topics list view 2025-05-22 01:46:08 +03:00
9b42d05174 start writing instructions (not complete yet) 2025-05-22 00:32:00 +03:00
fd261ec8c0 left-right margin 2025-05-21 20:05:50 +03:00
f8da57224f render top navbar in base 2025-05-21 20:00:21 +03:00
24c210e395 show page 1 even if there is nothing to page 2025-05-21 20:00:01 +03:00
f18e31811c add markup to thread create and topic edit 2025-05-21 19:57:08 +03:00
f5ba312032 add topic/thread list view 2025-05-21 17:34:24 +03:00
8e7b167bc2 clamp page query param in thread view 2025-05-21 17:34:02 +03:00
96922fdd76 add userboxes and use them instead of flash 2025-05-20 22:21:06 +03:00
ecf89dba19 add login, signup, settings, delete confirm markup 2025-05-20 19:08:21 +03:00
2eddb70d63 add user page markup 2025-05-20 17:05:45 +03:00
3bd474d7fe use 'me' instead of 'user' consistently 2025-05-20 14:28:23 +03:00
82b25946a0 buttons 2025-05-20 13:49:14 +03:00
a1055b0c43 correct some checks in user view 2025-05-20 13:20:34 +03:00
7cc16047cb add page titles 2025-05-20 13:12:50 +03:00
8c7ef09567 redirect to topics on root 2025-05-20 13:12:31 +03:00
f1f218fc75 split top nav into its own view 2025-05-20 13:12:05 +03:00
8609c33f00 add thread view 2025-05-20 12:30:41 +03:00
9b689a08e2 add sass 2025-05-20 06:56:14 +03:00
c473d2b1a0 more correct babycode parsing 2025-05-20 06:46:36 +03:00
00c56f1417 add is_system method to transient user 2025-05-19 18:36:18 +03:00
a5a7175365 add deleting, promoting/demoting, guesting (soft banning) users 2025-05-19 18:34:21 +03:00
349f4d38ef remove user id from post history table, not sure why it was there in the first place 2025-05-19 09:48:13 +03:00
70a780909a fix limit being hard coded to 20 2025-05-19 09:47:55 +03:00
6181701da6 add offset pagination and permalinking to posts 2025-05-19 09:33:30 +03:00
85b1319c79 some things 2025-05-19 09:12:59 +03:00
5ec458702a some cfg tweaks 2025-05-19 06:43:19 +03:00
91 changed files with 5137 additions and 358 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
logs/
nginx.conf.compiled
.vscode/
.local/
data/db/*
secrets
secrets/.touched*
sass

7
.gitignore vendored
View File

@ -1,6 +1,9 @@
logs/
nginx.conf.compiled
db.*.sqlite
.vscode/
.local/
static/
data/db/*
secrets/secrets.lua
secrets/.touched*
data/static/avatars/*
!data/static/avatars/default.webp

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# HOW TO:
#
# docker compose up
#
# it exposes the data/ and secrets/ volumes in app root
#
FROM openresty/openresty:1.25.3.2-5-alpine-fat
RUN apk add --no-cache git make gcc g++ musl-dev libffi-dev openssl-dev sqlite-dev libsodium libsodium-dev imagemagick-dev openssl
WORKDIR /app
COPY . .
RUN eval "$(luarocks --lua-version=5.1 path)"
# if using openresty images, make sure the image version is >= 1.25.3.2-5 or >= 1.27.1.2-2
# see https://github.com/openresty/docker-openresty/issues/276#issuecomment-2950726213
# otherwise, make sure your image uses luarocks >= 3.12.0
# see https://github.com/luarocks/luarocks/issues/1797
RUN luarocks --lua-version=5.1 build --only-deps
EXPOSE 8080
RUN chmod +x /app/start.sh
ENTRYPOINT ["/app/start.sh", "production"]

View File

@ -1,17 +1,61 @@
# Porom
porous forum
# Note
Development has moved over to [pyrom](https://git.poto.cafe/yagich/pyrom).
# License
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording.
# deps
this is all off the top of my head so if you try to run it got help you
# installing & first time setup
## docker
```bash
$ docker compose up
```
- lapis
- lsqlite3
- [magick](https://github.com/leafo/magick)
- bcrypt
- luaossl
- opens port 8080
- exposes `data/db` and `data/avatars` as volumes for data backup and persistence
- exposes `secrets/` as a volume so that the script won't try to perform first time setup again
i think thats it
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
## manual
1. install:
- OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html)
- LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
- openssl (-dev)
- sqlite (-dev)
- libsodium (-dev)
- imagemagick (-dev)
- [LuaRocks](https://luarocks.org) (either through the guide's instructions or your package manager, whichever is newer)
2. add luarocks search dirs to path:
```bash
# in .bashrc (or other shell equivalent)
eval "$(luarocks --lua-version 5.1 path)"
```
3. clone repo
4. install the lua dependencies:
```bash
$ luarocks --local --lua-version 5.1 build --only-deps
```
5. run:
```bash
$ start.sh production # or 'development' or empty string
```
the script will perform some necessary first time setup (and create a hidden file in the folder to ensure it won't do so again). it will create an administrator account and print the credentials to the console; **this will only happen once**. make sure you save them somewhere. the administrator account is the only one that can promote other users to moderator.
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database and shows more debug information.)
this app is made with the assumption that it is being reverse-proxied. as such, you may want to change the port to something other than the default `8080`. you can do that in [`config.lua`]([./config.lua]).
after the first time setup is complete, everything is ready to go. put the app behind your reverse proxy and serve it on the web. the app does not run in https by itself, but the reverse proxy can be set up to do that.
once you are able to navigate to the forum, you can log in as the administrator account. other people may also sign up, but they are not able to post until manually verified by an administrator or a moderator. the administrator can promote regular users to moderator.
# icons
the icons in the `icons/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)
# credits & acknowledgements
see [THIRDPARTY.md](./THIRDPARTY.md)

40
THIRDPARTY.md Normal file
View File

@ -0,0 +1,40 @@
# Acknowledgements
## Lapis
URL: https://leafo.net/lapis/
Copyright: `(c) 2023 Leaf Corcoran`
License: MIT
Repo: https://github.com/leafo/lapis
## ChicagoFLF
Affected files: [`fonts/ChicagoFLF.woff2`](./fonts/ChicagoFLF.woff2)
No canonical URL that I could find.
Obtained from: https://usemodify.com/fonts/chicago/
License: Public Domain
Designers: Susan Kare, Robin Casady
## Cadman
Affected files: [`fonts/Cadman_Bold.woff2`](./fonts/Cadman_Bold.woff2) [`fonts/Cadman_BoldItalic.woff2`](./fonts/Cadman_BoldItalic.woff2) [`fonts/Cadman_Italic.woff2`](./fonts/Cadman_Italic.woff2) [`fonts/Cadman_Roman.woff2`](./fonts/Cadman_Roman.woff2)
URL: https://localfonts.eu/shop/cyrillic-script/serbian/serbian-cyrillic-sans-serif/cadman/
Copyright: `© 2017-2020 by Paul James Miller. All rights reserved.`
License: SIL Open Font License 1.1
Designers: Paul James Miller
## ICONCINO
Affected files: [`svg-icons/error.etlua`](./svg-icons/error.etlua) [`svg-icons/image.etlua`](./svg-icons/image.etlua) [`svg-icons/info.etlua`](./svg-icons/info.etlua) [`svg-icons/lock.etlua`](./svg-icons/lock.etlua) [`svg-icons/sticky.etlua`](./svg-icons/sticky.etlua) [`svg-icons/warn.etlua`](./svg-icons/warn.etlua)
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina
License: CC0 1.0/CC BY 4.0
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
## Forumoji
Affected files: everything in [`data/static/emoji`](./data/static/emoji)
URL: https://gh.vercte.net/forumoji/
License: CC0 1.0
Designers: lolecksdeehaha; Scratch137; 64lu; stickfiregames; mybearworld (the project has many more contributors, but these are the people whose designs were reproduced here)

61
app.lua
View File

@ -1,26 +1,83 @@
local lapis = require("lapis")
local date = require("date")
local models = require("models")
local app = lapis.Application()
local constants = require("constants")
local babycode = require("lib.babycode")
local html_escape = require("lapis.html").escape
local config = require("lapis.config").get()
local db = require("lapis.db")
-- sqlite starts without foreign key enforcement
db.query("PRAGMA foreign_keys = ON")
local util = require("util")
app:enable("etlua")
app.layout = require "views.base"
app.cookie_attributes = function (self, name, value)
if name == config.session_name then
if not self.session.queue_delete then
local expires = date(true):adddays(30):fmt("${http}")
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
else
local expires = date(true):addseconds(-30):fmt("${http}")
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
end
end
end
local function inject_constants(req)
req.constants = constants
math.randomseed(os.time())
req.__cachebust = math.random(99999)
req.__commit = config.commit
end
local function inject_methods(req)
req.avatar_url = util.get_user_avatar_url
req.ntob = function(_, v)
return util.ntob(v)
end
req.PermissionLevelString = constants.PermissionLevelString
req.infobox_message = function (_, s)
return util.infobox_message(s)
end
req.babycode_to_html = function (_, bb)
return babycode.to_html(bb, html_escape)
end
req.get_thread_by_id = function(_, id)
return models.Threads:find({id = id})
end
req.get_post_url = function(_, id)
return util.get_post_url(_, id)
end
util.pop_infobox(req)
end
app:before_filter(inject_constants)
app:before_filter(inject_methods)
app:include("apps.users", {path = "/user"})
app:include("apps.topics", {path = "/topics"})
app:include("apps.threads", {path = "/threads"})
app:include("apps.mod", {path = "/mod"})
app:include("apps.post", {path = "/post"})
app:include("apps.api", {path = "/api"})
app:get("/", function()
return "Welcome to Lapis " .. require("lapis.version")
app:get("/", function(self)
return {redirect_to = self:url_for("all_topics")}
end)
app:get("babycode_guide", "/babycode", function(self)
self.me = util.get_logged_in_user_or_transient(self)
self.page_title = "babycode guide"
return {render = "babycode"}
end)
return app

48
apps/api.lua Normal file
View File

@ -0,0 +1,48 @@
local app = require("lapis").Application()
local json_params = require("lapis.application").json_params
local db = require("lapis.db")
local html_escape = require("lapis.html").escape
local babycode = require("lib.babycode")
local util = require("util")
app:post("api_get_thread_updates", "/thread-updates/:thread_id", json_params(function(self)
local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id)
if #thread == 0 then
return {json = {error = "no such thread"}, status = 404}
end
local target_time = self.params.since
if not target_time then
return {json = {error = "missing parameter 'since'"}, status = 400}
end
if not tonumber(target_time) then
return {json = {error = "parameter 'since' is not a number"}, status = 400}
end
local new_posts_query = "SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1"
local new_post = db.query(new_posts_query, self.params.thread_id, target_time)
if #new_post == 0 then
return {json = {status = "none"}, status = 200}
end
local url = util.get_post_url(self, new_post[1].id)
return {json = {status = "new_post", url = url}}
end))
app:post("babycode_preview", "/babycode-preview", json_params(function(self)
local user = util.get_logged_in_user(self)
if not user then
return {json = {error = "not authorized"}, status = 401}
end
if not util.rate_limit_allowed(user.id, "babycode_preview", 5) then
return {json = {error = "too many requests"}, status = 429}
end
local markup = self.params.markup
if not markup or type(markup) ~= "string" then
return {json = {error = "markup field missing or invalid type"}, status = 400}
end
local rendered = babycode.to_html(markup, html_escape)
return {json = {html = rendered}}
end))
return app

46
apps/mod.lua Normal file
View File

@ -0,0 +1,46 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local util = require("util")
local models = require("models")
local Users = models.Users
-- everything here requires a logged in moderator
app:before_filter(function(self)
self.me = util.get_logged_in_user(self)
if not self.me then
self:write{redirect_to = self:url_for("all_topics")}
return
end
if not self.me:is_mod() then
self:write{redirect_to = self:url_for("all_topics")}
return
end
end)
app:get("user_list", "/list", function(self)
self.users = Users:select("")
return {render = "mod.user-list"}
end)
app:get("sort_topics", "/sort-topics", function(self)
self.topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
self.page_title = "sorting topics"
return {render = "mod.sort-topics"}
end)
app:post("sort_topics", "/sort-topics", function(self)
local updates = self.params
db.query("BEGIN")
for topic_id, new_order in pairs(updates) do
db.update("topics", {sort_order = new_order}, {id = topic_id})
end
db.query("COMMIT")
return {redirect_to = self:url_for("sort_topics")}
end)
return app

104
apps/post.lua Normal file
View File

@ -0,0 +1,104 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local constants = require("constants")
local util = require("util")
local models = require("models")
local Posts = models.Posts
local Threads = models.Threads
local PostHistory = models.PostHistory
app:get("single_post", "/:post_id", function(self)
local query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
local p = db.query(query, self.params.post_id)
if p then
self.post = p[1]
self.thread = Threads:find({id = self.post.thread_id})
self.page_title = self.post.username .. "'s post in " .. self.thread.title
end
return {render = "post.single-post"}
end)
app:post("delete_post", "/:post_id/delete", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for"all_topics"}
end
print("id is " .. self.params.post_id)
local post = Posts:find({id = self.params.post_id})
if not post then
return {redirect_to = self:url_for"all_topics"}
end
local thread = Threads:find({id = post.thread_id})
if user:is_mod() then
post:delete()
util.inject_infobox(self, "Post deleted.")
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
if post.user_id ~= user.id then
return {redirect_to = self:url_for"all_topics"}
end
post:delete()
util.inject_infobox(self, "Post deleted.")
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end)
app:get("edit_post", "/:post_id/edit", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for"all_topics"}
end
local editing_query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
local p = db.query(editing_query, self.params.post_id)
if not p then
return {redirect_to = self:url_for"all_topics"}
end
if p[1].user_id ~= user.id then
return {redirect_to = self:url_for"all_topics"}
end
self.me = user
self.editing_post = p[1]
self.thread = Threads:find({id = self.editing_post.thread_id})
local thread_predicate = constants.FULL_POSTS_QUERY .. "WHERE posts.thread_id = ?\n"
local context_prev_query = thread_predicate .. "AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2"
local context_next_query = thread_predicate .. "AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2"
self.prev_context = db.query(context_prev_query, self.thread.id, self.editing_post.created_at)
self.next_context = db.query(context_next_query, self.thread.id, self.editing_post.created_at)
self.page_title = "editing a post"
return {render = "post.edit-post"}
end)
app:post("edit_post", "/:post_id/edit", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("all_topics")}
end
local post = Posts:find({id = self.params.post_id})
if not post then
return {redirect_to = self:url_for("all_topics")}
end
if post.user_id ~= user.id then
return {redirect_to = self:url_for("all_topics")}
end
util.update_post(post, self.params.new_content)
local thread = Threads:find({id = post.thread_id})
local link = self:url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id
return {redirect_to = link}
end)
return app

View File

@ -1,5 +1,6 @@
local app = require("lapis").Application()
local lapis_util = require("lapis.util")
local constants = require("constants")
local db = require("lapis.db")
local util = require("util")
@ -8,11 +9,14 @@ local models = require("models")
local Topics = models.Topics
local Threads = models.Threads
local Posts = models.Posts
local Subscriptions = models.Subscriptions
local POSTS_PER_PAGE = 10
app:get("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self)
if not user then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local all_topics = db.query("select * from topics limit 25;")
@ -20,18 +24,23 @@ app:get("thread_create", "/create", function(self)
return "how did you get here?"
end
self.all_topics = all_topics
self.page_title = "drafting a thread"
self.me = user
return {render = "threads.create"}
end)
app:post("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self)
if not user then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local topic = Topics:find(self.params.topic_id)
if not topic then
return {redirect_to = self:url_for("topics")}
return {redirect_to = self:url_for("all_topics")}
end
if util.is_topic_locked(topic) and not user:is_mod() then
return {redirect_to = self:url_for("all_topics")}
end
local title = lapis_util.trim(self.params.title)
@ -50,7 +59,7 @@ app:post("thread_create", "/create", function(self)
local post = util.create_post(thread.id, user.id, post_content)
if not post then
return {redirect_to = self:url_for("topics")}
return {redirect_to = self:url_for("all_topics")}
end
return {redirect_to = self:url_for("thread", {slug = slug})}
@ -64,26 +73,45 @@ app:get("thread", "/:slug", function(self)
return {status = 404}
end
self.thread = thread
local posts = db.query([[
SELECT
posts.id, post_history.content, users.username, avatars.file_path AS avatar_path
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
WHERE
posts.thread_id = ? and posts.id > ?
ORDER BY
posts.created_at ASC
LIMIT 20
]], thread.id, tonumber(self.params.cursor or 0))
self.user = util.get_logged_in_user_or_transient(self)
local post_count = Posts:count(db.clause({
thread_id = thread.id
}))
self.pages = math.max(math.ceil(post_count / POSTS_PER_PAGE), 1)
if self.params.after then
local after_id = tonumber(self.params.after)
local post_position = Posts:count(db.clause({
thread_id = thread.id,
{"id <= ?", after_id},
}))
self.page = math.floor((post_position - 1) / POSTS_PER_PAGE) + 1
else
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
end
-- self.page = math.max(1, math.min(self.page, self.pages))
local query = (constants.FULL_POSTS_QUERY ..
"WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?")
local posts = db.query(query, thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
self.topic = Topics:find(thread.topic_id)
self.other_topics = db.query("SELECT topics.id, topics.name FROM topics")
self.me = util.get_logged_in_user_or_transient(self)
self.posts = posts
self.next_cursor = #posts > 0 and posts[#posts].id or nil
if self.me:is_logged_in() then
self.is_subscribed = false
local subscription = Subscriptions:find({user_id = self.me.id, thread_id = thread.id})
if subscription then
self.is_subscribed = true
if posts[#posts].created_at > subscription.last_seen then
subscription:update({last_seen = os.time()})
end
end
end
self.page_title = thread.title
return {render = "threads.thread"}
end)
@ -103,17 +131,145 @@ app:post("thread", "/:slug", function(self)
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
if util.is_thread_locked(thread) and not user:is_admin() then
if util.is_thread_locked(thread) and not user:is_mod() then
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
local post_content = self.params.post_content
local post = util.create_post(thread.id, user.id, post_content)
local post_count = Posts:count(db.clause({
thread_id = thread.id
}))
local last_page = math.ceil(post_count / POSTS_PER_PAGE)
if not post then
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
end
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
if subscription then
subscription:update({last_seen = os.time()})
end
if self.params.subscribe == "on" and not subscription then
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = os.time()})
end
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
end)
app:post("thread_lock", "/:slug/lock", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local thread = Threads:find({slug = self.params.slug})
if not ((thread.user_id == user.id) or user:is_mod()) then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local target_op = util.form_bool_to_sqlite(self.params.target_op)
thread:update({
is_locked = target_op,
})
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end)
app:post("thread_sticky", "/:slug/sticky", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
if not user:is_mod() then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local thread = Threads:find({slug = self.params.slug})
local target_op = util.form_bool_to_sqlite(self.params.target_op)
thread:update({
is_stickied = target_op,
})
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end)
app:post("thread_move", "/:slug/move", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
if not user:is_mod() then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
if not self.params.new_topic_id then
util.inject_err_infobox(self, "Thread already in this topic.")
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local new_topic = Topics:find({id = self.params.new_topic_id})
if not new_topic then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local thread = Threads:find({slug = self.params.slug})
if not thread then
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
if new_topic.id == thread.topic_id then
util.inject_err_infobox(self, "Thread already in this topic.")
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end
local old_topic = Topics:find({id = thread.topic_id})
thread:update({topic_id = new_topic.id})
util.inject_infobox(self, ("Thread moved from \"%s\" to \"%s\"."):format(old_topic.name, new_topic.name))
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
end)
app:post("thread_subscribe", "/:slug/subscribe", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {status = 403}
end
local thread = Threads:find({slug = self.params.slug})
if not thread then
return {status = 404}
end
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
if self.params.subscribe == "subscribe" then
local now = os.time()
if subscription then
subscription:delete()
end
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = now})
if self.params.last_visible_post then
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
else
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
end
elseif self.params.subscribe == "unsubscribe" then
if not subscription then
return {status = 404}
end
subscription:delete()
if self.params.last_visible_post then
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
else
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
end
elseif self.params.subscribe == "read" then
if not subscription then
return {status = 404}
end
subscription:update({last_seen = os.time()})
if self.params.last_visible_post then
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
else
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
end
end
return {status = 400}
end)
return app

View File

@ -12,6 +12,8 @@ local Avatars = models.Avatars
local Topics = models.Topics
local Threads = models.Threads
local THREADS_PER_PAGE = 10
local ThreadCreateError = {
OK = 0,
GUEST = 1,
@ -20,23 +22,84 @@ local ThreadCreateError = {
}
app:get("all_topics", "", function(self)
self.topic_list = db.query("select * from topics limit 25;")
self.user = util.get_logged_in_user(self) or util.TransientUser
self.topic_list = db.query([[
SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username,
threads.title AS latest_thread_title,
threads.slug AS latest_thread_slug,
threads.created_at AS latest_thread_created_at
FROM
topics
LEFT JOIN (
SELECT
*,
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
FROM
threads
) threads ON threads.topic_id = topics.id AND threads.rn = 1
LEFT JOIN
users on users.id = threads.user_id
ORDER BY
topics.sort_order ASC
]])
local active_threads_raw = db.query([[
WITH ranked_threads AS (
SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
posts.id AS post_id, posts.created_at AS post_created_at,
users.username,
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
FROM
threads
JOIN
posts ON threads.id = posts.thread_id
LEFT JOIN
users ON posts.user_id = users.id
)
SELECT
topic_id,
thread_id, thread_title, thread_slug,
post_id, post_created_at,
username
FROM
ranked_threads
WHERE
rn = 1
ORDER BY
topic_id
]])
self.active_threads = {}
for _, thread in ipairs(active_threads_raw) do
self.active_threads[tonumber(thread.topic_id)] = {
thread_title = thread.thread_title,
thread_slug = thread.thread_slug,
post_id = thread.post_id,
username = thread.username,
post_created_at = thread.post_created_at,
}
end
self.me = util.get_logged_in_user_or_transient(self)
return {render = "topics.topics"}
end)
app:get("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser
if not user:is_admin() then
if not user:is_mod() then
return {status = 403}
end
self.page_title = "creating a topic"
self.me = user
return {render = "topics.create"}
end)
app:post("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser
if not user:is_admin() then
if not user:is_mod() then
return {redirect_to = "all_topics"}
end
@ -45,13 +108,17 @@ app:post("topic_create", "/create", function(self)
local time = os.time()
local slug = lapis_util.slugify(topic_name) .. "-" .. time
local topic_count = Topics:count()
local topic = Topics:create({
name = topic_name,
description = topic_description,
slug = slug,
sort_order = topic_count + 1,
})
return {redirect_to = self:url_for("all_topics")}
util.inject_infobox(self, "Topic created.")
return {redirect_to = self:url_for("topic", {slug = topic.slug})}
end)
app:get("topic", "/:slug", function(self)
@ -61,29 +128,73 @@ app:get("topic", "/:slug", function(self)
if not topic then
return {status = 404}
end
self.topic = topic
self.threads_list = Threads:select(db.clause({
local threads_count = Threads:count(db.clause({
topic_id = topic.id
}))
self.topic = topic
local sort_by = self.session.sort_by or "activity"
local order_clause = ""
if sort_by == "thread" then
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
else
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
end
local query = [[
SELECT
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by,
u.username AS latest_post_username,
ph.content AS latest_post_content,
posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id
FROM
threads
JOIN users ON users.id = threads.user_id
JOIN (
SELECT
posts.thread_id,
posts.id,
posts.user_id,
posts.created_at,
posts.current_revision_id,
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
FROM
posts
) posts ON posts.thread_id = threads.id AND posts.rn = 1
JOIN
post_history ph ON ph.id = posts.current_revision_id
JOIN
users u ON u.id = posts.user_id
WHERE
threads.topic_id = ?
]] .. order_clause .. " LIMIT ? OFFSET ?"
self.pages = math.max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
self.threads_list = db.query(query, topic.id, THREADS_PER_PAGE, (self.page - 1) * THREADS_PER_PAGE)
local user = util.get_logged_in_user_or_transient(self)
print(topic.is_locked, type(topic.is_locked))
self.user = user
self.me = user
self.ThreadCreateError = ThreadCreateError
self.thread_create_error = ThreadCreateError.OK
if user:is_logged_in_guest() then
self.thread_create_error = ThreadCreateError.GUEST
elseif user:is_guest() then
self.thread_create_error = ThreadCreateError.LOGGED_OUT
elseif util.ntob(topic.is_locked) and not user:is_admin() then
elseif util.ntob(topic.is_locked) and not user:is_mod() then
self.thread_create_error = ThreadCreateError.TOPIC_LOCKED
end
self.page_title = "browsing topic " .. topic.name
return {render = "topics.topic"}
end)
app:get("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(self)
if not user:is_admin() then
if not user:is_mod() then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
local topic = Topics:find({
@ -93,12 +204,15 @@ app:get("topic_edit", "/:slug/edit", function(self)
return {redirect_to = self:url_for("all_topics")}
end
self.topic = topic
self.me = user
self.page_title = "editing topic " .. topic.name
return {render = "topics.edit"}
end)
app:post("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(self)
if not user:is_admin() then
if not user:is_mod() then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
local topic = Topics:find({
@ -122,4 +236,20 @@ app:post("topic_edit", "/:slug/edit", function(self)
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end)
app:post("topic_delete", "/:slug/delete", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
if not user:is_mod() then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
local topic = Topics:find({slug = self.params.slug})
topic:delete()
util.inject_infobox(self, "Topic deleted.")
return {redirect_to = self:url_for("all_topics")}
end)
return app

View File

@ -1,20 +1,23 @@
local app = require("lapis").Application()
local babycode = require("lib.babycode")
local html_escape = require("lapis.html").escape
local db = require("lapis.db")
local constants = require("constants")
local util = require("util")
local bcrypt = require("bcrypt")
local auth = require("lib.auth")
local rand = require("openssl.rand")
local models = require("models")
local Users = models.Users
local Sessions = models.Sessions
local Avatars = models.Avatars
local Subscriptions = models.Subscriptions
local function authenticate_user(user, password)
return bcrypt.verify(password, user.password_hash)
return auth.verify(password, user.password_hash)
end
local function create_session_key()
@ -66,47 +69,127 @@ app:get("user", "/:username", function(self)
return {status = 404}
end
if self.session.flash ~= nil and self.session.flash.just_logged_in then
self.just_logged_in = true
self.session.flash = {}
end
-- local me = validate_session(self.session.session_key) or TransientUser
local me = util.get_logged_in_user(self) or util.TransientUser
local me = util.get_logged_in_user_or_transient(self)
self.user = user
self.me = me
self.user_is_me = me.id == user.id
if user.permission == constants.PermissionLevel.GUEST then
if not (self.user_is_me or me:is_admin()) then
if not (self.user_is_me or me:is_mod()) then
return {status = 404}
end
end
self.stats = db.query([[
SELECT
COUNT(posts.id) AS post_count,
COUNT(DISTINCT threads.id) AS thread_count,
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug
FROM users
LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.user_id = users.id
LEFT JOIN (
SELECT user_id, MAX(created_at) AS created_at
FROM threads
GROUP BY user_id
) latest ON latest.user_id = users.id
WHERE users.id = ?
]], user.id)[1]
self.latest_posts = db.query([[
SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
threads ON posts.thread_id = threads.id
JOIN
topics ON threads.topic_id = topics.id
WHERE
posts.user_id = ?
ORDER BY posts.created_at DESC
LIMIT 10
]], user.id)
self.page_title = user.username .. "'s profile"
return {render = "user.user"}
end)
app:post("user_clear_avatar", "/:username/clear_avatar", function(self)
app:post("user_delete", "/:username/delete", function(self)
-- this route explicitly does not handle admins deleting other users
-- i might make a separate route for it later, but guesting users is possible
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
if me:is_admin() then
util.inject_err_infobox("You can not delete the admin account!")
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
if not authenticate_user(target_user, self.params.password) then
util.inject_err_infobox(self, "The password you entered is incorrect.")
return {redirect_to = self:url_for("user_delete_confirm", {username = me.username})}
end
local session = Sessions:find({key = self.session.session_key})
session:delete()
self.session.queue_delete = true
util.transfer_and_delete_user(target_user)
return {redirect_to = self:url_for("user_signup")}
end)
app:get("user_delete_confirm", "/:username/delete_confirm", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
-- util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
self.me = target_user
self.page_title = "confirm deletion"
return {render = "user.delete_confirm"}
end)
app:post("user_clear_avatar", "/:username/clear_avatar", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
local old_avatar_id = target_user.avatar_id
target_user:update({
avatar_id = db.NULL,
avatar_id = 1,
})
self.session.flash = {success = true, msg = "Avatar cleared."}
util.destroy_avatar(old_avatar_id)
util.inject_infobox(self, "Avatar cleared.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:post("user_set_avatar", "/:username/set_avatar", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
@ -115,20 +198,20 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
end
local file = self.params.avatar
if not file then
self.session.flash = {error = "Something went wrong. Try again later."}
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
local time = os.time()
local filename = "u" .. target_user.id .. "d" .. time .. ".webp"
local proxied_filename = "/avatars/" .. filename
local save_path = "static" .. proxied_filename
local save_path = "data/static" .. proxied_filename
local res = util.validate_and_create_image(file.content, save_path)
if not res then
self.session.flash = {error = "Something went wrong. Try again later."}
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
self.session.flash = {success = true, msg = "Avatar updated."}
util.inject_infobox(self, "Avatar updated.")
local avatar = Avatars:create({
file_path = proxied_filename,
uploaded_at = time,
@ -141,52 +224,182 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:post("user_change_password", "/:username/new_password", function(self)
local me = util.get_logged_in_user(self)
if not me then
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
local password = self.params.new_password
local password2 = self.params.new_password2
if not validate_password(password) then
util.inject_err_infobox(self, "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
if password ~= password2 then
util.inject_err_infobox(self, "Passwords do not match.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
me:update({
password_hash = auth.digest(password)
})
util.extend_session_cookie(self)
util.inject_infobox(self, "Password updated.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:get("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
if self.session.flash then
local flash = self.session.flash
self.session.flash = nil
if flash.success then
self.flash_msg = flash.msg
elseif flash.error then
self.flash_msg = flash.error
end
end
self.user = target_user
self.me = target_user
self.page_title = "settings"
return {render = "user.settings"}
end)
app:post("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
if self.params.topic_sort_by == "activity" or self.params.topic_sort_by == "thread" then
self.session.sort_by = self.params.topic_sort_by
end
local status = self.params.status:sub(1, 100)
local original_sig = self.params.signature or ""
local rendered_sig = babycode.to_html(original_sig, html_escape)
if self.params.subscribe_by_default == "on" then
self.session.subscribe_by_default = true
else
self.session.subscribe_by_default = false
end
target_user:update({
status = status,
signature_original_markup = original_sig,
signature_rendered = rendered_sig,
})
self.session.flash = {
success = true,
msg = "Settings updated."
}
util.inject_infobox(self, "Settings updated.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:get("user_inbox", "/:username/inbox", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
util.inject_err_infobox(self, "You must be logged in to perform this action.")
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user_inbox", {username = me.username})}
end
self.me = target_user
self.page_title = "inbox"
self.new_posts = {}
local subscription = Subscriptions:find({user_id = me.id})
if subscription then
self.all_subscriptions = db.query([[
SELECT threads.title AS thread_title, threads.slug AS thread_slug
FROM
threads
JOIN
subscriptions ON subscriptions.thread_id = threads.id
WHERE
subscriptions.user_id = ?
]], me.id)
local q = [[
WITH thread_metadata AS (
SELECT
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
FROM
posts
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
GROUP BY posts.thread_id
)
SELECT
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
FROM
thread_metadata tm
JOIN
posts ON posts.thread_id = tm.thread_id
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
ORDER BY
tm.newest_post_time DESC, posts.created_at ASC]]
local new_posts_raw = db.query(q, me.id, me.id)
local threads = {}
local current_thread_id = nil
local current_thread_group = nil
self.total_unreads_count = 0
for _, row in ipairs(new_posts_raw) do
if row.thread_id ~= current_thread_id then
current_thread_group = {
thread_id = row.thread_id,
thread_title = row.thread_title,
unread_count = row.unread_count,
thread_slug = row.thread_slug,
newest_post_time = row.newest_post_time,
posts = {}
}
self.total_unreads_count = self.total_unreads_count + row.unread_count
table.insert(threads, current_thread_group)
current_thread_id = row.thread_id
end
---@diagnostic disable-next-line: need-check-nil
table.insert(current_thread_group.posts, {
id = row.id,
created_at = row.created_at,
content = row.content,
edited_at = row.edited_at,
username = row.username,
status = row.status,
avatar_path = row.avatar_path,
thread_id = row.thread_id,
user_id = row.user_id,
original_markup = row.original_markup,
signature_rendered = row.signature_rendered,
})
end
self.new_posts = threads
end
return {render = "user.inbox"}
end)
app:get("user_login", "/login", function(self)
if self.session.session_key then
local user = util.get_logged_in_user(self)
@ -195,10 +408,8 @@ app:get("user_login", "/login", function(self)
end
end
if self.session.flash then
self.err = self.session.flash.error
self.session.flash = {}
end
self.page_title = "log in"
return {render = "user.login"}
end)
@ -213,15 +424,19 @@ app:post("user_login", "/login", function(self)
local password = self.params.password
local user = Users:find({username = username})
if not user then
self.session.flash = {error = "Invalid username or password"}
util.inject_err_infobox(self, "Invalid username or password")
return {redirect_to = self:url_for("user_login")}
end
if user.permission == constants.PermissionLevel.SYSTEM then
util.inject_err_infobox(self, "Invalid username or password")
return {redirect_to = self:url_for("user_login")}
end
if not authenticate_user(user, password) then
self.session.flash = {error = "Invalid username or password"}
util.inject_err_infobox(self, "Invalid username or password")
return {redirect_to = self:url_for("user_login")}
end
local session = create_session(user.id)
self.session.flash = {just_logged_in = true}
util.inject_infobox(self, "Logged in successfully.")
self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})}
end)
@ -233,10 +448,9 @@ app:get("user_signup", "/signup", function(self)
return {redirect_to = self:url_for("user", {username = user.username})}
end
end
if self.session.flash then
self.err = self.session.flash.error
self.session.flash = {}
end
self.page_title = "sign up"
return {render = "user.signup"}
end)
@ -253,33 +467,33 @@ app:post("user_signup", "/signup", function(self)
local password2 = self.params.password2
local user = Users:find({username = username})
if user then
self.session.flash = {error = "Username '" .. username .. "' is already taken."}
util.inject_err_infobox(self, "Username '" .. username .. "' is already taken.")
return {redirect_to = self:url_for("user_signup")}
end
if not validate_username(username) then
self.session.flash = {error = "Username must be 3-20 characters with only upper and lowercase letters, hyphens, and underscores."}
util.inject_err_infobox(self, "Username must be 3-20 characters with only upper and lowercase letters, hyphens, and underscores.")
return {redirect_to = self:url_for("user_signup")}
end
if not validate_password(password) then
self.session.flash = {error = "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces."}
util.inject_err_infobox(self, "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces.")
return {redirect_to = self:url_for("user_signup")}
end
if password ~= password2 then
self.session.flash = {error = "Passwords do not match."}
util.inject_err_infobox(self, "Passwords do not match.")
return {redirect_to = self:url_for("user_signup")}
end
local new_user = Users:create({
username = username,
password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS),
password_hash = auth.digest(password),
permission = constants.PermissionLevel.GUEST,
})
local session = create_session(new_user.id)
self.session.flash = {just_logged_in = true}
util.inject_infobox(self, "Siged up successfully.")
self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})}
end)
@ -292,6 +506,7 @@ app:post("user_logout", "/logout", function (self)
local session = Sessions:find({key = self.session.session_key})
session:delete()
self.session.queue_delete = true
return {redirect_to = self:url_for("user_login")}
end)
@ -300,7 +515,7 @@ app:post("confirm_user", "/confirm_user/:user_id", function (self)
if not user then
return {status = 403}
end
if not user:is_admin() then
if not user:is_mod() then
return {status = 403}
end
local target_user = Users:find(self.params.user_id)
@ -315,4 +530,64 @@ app:post("confirm_user", "/confirm_user/:user_id", function (self)
return {redirect_to = self:url_for("user", {username = target_user.username})}
end)
app:post("mod_user", "/mod_user/:user_id", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {status = 403}
end
if not user:is_admin() then
return {status = 403}
end
local target_user = Users:find(self.params.user_id)
if not target_user then
return {status = 404}
end
if target_user:is_mod() then
return {status = 404}
end
target_user:update({permission = constants.PermissionLevel.MODERATOR})
return {redirect_to = self:url_for("user", {username = target_user.username})}
end)
app:post("demod_user", "/demod_user/:user_id", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {status = 403}
end
if not user:is_admin() then
return {status = 403}
end
local target_user = Users:find(self.params.user_id)
if not target_user then
return {status = 404}
end
if not target_user:is_mod() then
return {status = 404}
end
target_user:update({permission = constants.PermissionLevel.USER})
return {redirect_to = self:url_for("user", {username = target_user.username})}
end)
app:post("guest_user", "/guest_user/:user_id", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {status = 403}
end
if not user:is_mod() then
return {status = 403}
end
local target_user = Users:find(self.params.user_id)
if not target_user then
return {status = 404}
end
if target_user:is_mod() then
return {status = 404}
end
target_user:update({permission = constants.PermissionLevel.GUEST})
return {redirect_to = self:url_for("user", {username = target_user.username})}
end)
return app

View File

@ -1,12 +1,33 @@
local config = require("lapis.config")
local secrets = require("secrets.secrets")
config("development", {
local commit = nil
local f = io.open(".git/refs/heads/main", "r")
if f then
commit = f:read(8)
f:close()
end
config({"development", "production"}, {
port = 8080,
server = "nginx",
code_cache = "off",
num_workers = "1",
sqlite = {
database = "db.dev.sqlite"
database = "data/db/db.dev.sqlite"
},
secret = "SUPER SECRET",
session_name = "porom_session",
commit = commit,
})
config("production", {
code_cache = "on",
logging = {
queries = false,
},
secret = secrets.key,
sqlite = {
database = "data/db/db.prod.sqlite"
},
session_name = "porom_session_s"
})

View File

@ -3,7 +3,50 @@ local Constants = {}
Constants.PermissionLevel = {
GUEST = 0,
USER = 1,
ADMIN = 2,
MODERATOR = 2,
SYSTEM = 3,
ADMIN = 4,
}
Constants.FULL_POSTS_QUERY = [[
SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
]]
Constants.PermissionLevelString = {
[Constants.PermissionLevel.GUEST] = "Guest",
[Constants.PermissionLevel.USER] = "User",
[Constants.PermissionLevel.MODERATOR] = "Moderator",
[Constants.PermissionLevel.SYSTEM] = "System",
[Constants.PermissionLevel.ADMIN] = "Administrator",
}
Constants.InfoboxKind = {
INFO = 0,
LOCK = 1,
WARN = 2,
ERROR = 3,
}
Constants.InfoboxIcons = {
[Constants.InfoboxKind.INFO] = "svg-icons.info",
[Constants.InfoboxKind.LOCK] = "svg-icons.lock",
[Constants.InfoboxKind.WARN] = "svg-icons.warn",
[Constants.InfoboxKind.ERROR] = "svg-icons.error",
}
Constants.InfoboxHTMLClass = {
[Constants.InfoboxKind.INFO] = "",
[Constants.InfoboxKind.LOCK] = "warn",
[Constants.InfoboxKind.WARN] = "warn",
[Constants.InfoboxKind.ERROR] = "critical",
}
Constants.BCRYPT_ROUNDS = 10

View File

@ -1,9 +1,20 @@
local bcrypt = require("bcrypt")
local auth = require("lib.auth")
local models = require("models")
local constants = require("constants")
local alphabet = "-_@0123456789abcdefghijklmnopqrstuvwABCDEFGHIJKLMNOPQRSTUVWXYZ"
local function create_default_avatar()
if models.Avatars:count() > 0 then
print("default avatar must exist")
return
end
models.Avatars:create({
file_path = "/avatars/default.webp",
uploaded_at = os.time(),
})
end
local function create_admin()
local username = "admin"
local root_count = models.Users:count("username = ?", username)
@ -12,13 +23,14 @@ local function create_admin()
return
end
math.randomseed(os.time())
local password = ""
for _ = 1, 16 do
local randi = math.random(#alphabet)
password = password .. alphabet:sub(randi, randi)
end
local hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS)
local hash = auth.digest(password)
models.Users:create({
username = username,
@ -29,4 +41,21 @@ local function create_admin()
print("Admin account created, use \"admin\" as the login and \"" .. password .. "\" as the password. This will only be shown once.")
end
local function create_deleted_user()
local username = "DeletedUser"
local root_count = models.Users:count("username = ?", username)
if root_count ~= 0 then
print("deleted user already exists")
return
end
models.Users:create({
username = username,
password_hash = "",
permission = constants.PermissionLevel.SYSTEM,
})
end
create_default_avatar()
create_admin()
create_deleted_user()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
data/static/emoji/angry.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

BIN
data/static/emoji/frown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

BIN
data/static/emoji/grin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

BIN
data/static/emoji/imp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

BIN
data/static/emoji/smile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

BIN
data/static/emoji/sob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

BIN
data/static/emoji/think.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

BIN
data/static/emoji/weary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

BIN
data/static/emoji/wink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

735
data/static/style.css Normal file
View File

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

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
version: "3"
services:
porom:
build:
context: .
ports:
- "8080:8080"
volumes:
- ./data/static:/app/data/static
- ./data/db:/app/data/db
- ./secrets:/app/secrets
restart: unless-stopped

BIN
fonts/Cadman_Bold.woff2 Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Cadman_Italic.woff2 Normal file

Binary file not shown.

BIN
fonts/Cadman_Roman.woff2 Normal file

Binary file not shown.

BIN
fonts/ChicagoFLF.woff2 Normal file

Binary file not shown.

159
js/babycode-editor.js Normal file
View File

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

7
js/copy-code.js Normal file
View File

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

10
js/date-fmt.js Normal file
View File

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

45
js/sort-topics.js Normal file
View File

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

80
js/thread.js Normal file
View File

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

16
js/topic.js Normal file
View File

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

147
js/ui.js Normal file
View File

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

16
lib/auth.lua Normal file
View File

@ -0,0 +1,16 @@
local auth = {}
local ls = require "luasodium"
function auth.digest(password)
return ls.crypto_pwhash_str(
password,
ls.crypto_pwhash_OPSLIMIT_INTERACTIVE,
ls.crypto_pwhash_MEMLIMIT_INTERACTIVE)
end
function auth.verify(password, hash)
return ls.crypto_pwhash_str_verify(hash, password)
end
return auth

47
lib/babycode-emoji.lua Normal file
View File

@ -0,0 +1,47 @@
local emoji_template = " <img class=emoji src=\"/emoji/$NAME.png\" alt=\"$NAME\" title=\":$CODE:\"> "
local emoji_pat = "%$NAME"
local name_pat = "%$CODE"
return {
["angry"] = emoji_template:gsub(emoji_pat, "angry"):gsub(name_pat, "angry"),
["("] = emoji_template:gsub(emoji_pat, "frown"):gsub(name_pat, "("),
["D"] = emoji_template:gsub(emoji_pat, "grin"):gsub(name_pat, "D"),
["imp"] = emoji_template:gsub(emoji_pat, "imp"):gsub(name_pat, "imp"),
["angryimp"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "angryimp"),
["impangry"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "impangry"),
["lobster"] = emoji_template:gsub(emoji_pat, "lobster"):gsub(name_pat, "lobster"),
["|"] = emoji_template:gsub(emoji_pat, "neutral"):gsub(name_pat, "|"),
["pensive"] = emoji_template:gsub(emoji_pat, "pensive"):gsub(name_pat, "pensive"),
[")"] = emoji_template:gsub(emoji_pat, "smile"):gsub(name_pat, ")"),
["smiletear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "smiletear"),
["crytear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "crytear"),
[","] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, ","),
["T"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "T"),
["cry"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "cry"),
["sob"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "sob"),
["o"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "o"),
["O"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "O"),
["hmm"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "hmm"),
["think"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "think"),
["thinking"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "thinking"),
["P"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "P"),
["p"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "p"),
["weary"] = emoji_template:gsub(emoji_pat, "weary"):gsub(name_pat, "weary"),
[";"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_pat, ";"),
["wink"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_pat, "wink"),
}

416
lib/babycode-parser.lua Normal file
View File

@ -0,0 +1,416 @@
-- contributed by kaesa
--- Pattern used for emote names (applied for every char).
local PAT_EMOTE = "[^%s:]"
--- Pattern used for bbcode tags (applied for every char).
local PAT_BBCODE_TAG = "%w"
--- Pattern used for bbcode tag attribute (applied for every char).
local PAT_BBCODE_ATTR = "[^%s%]]"
--- Pattern used to detect loose links.
local PAT_LINK = "https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/]"
--- @class Parser
--- @field valid_bbcode_tags table Table of valid BBCode tags.
--- @field valid_emotes table Table of valid emotes.
--- @field bbcode_tags_only_text_children table Table of tags that might only containt text.
--- @field source string Source to parse.
--- @field position integer Current position of the parser.
--- @field position_stack integer[] Position stack used for rewind parsing.
---
--- Parser class.
local Parser = {}
--- Creates a new parser.
---
--- @param src string
--- @return Parser
function Parser.new(src)
local inst = {
valid_bbcode_tags = {},
valid_emotes = {},
bbcode_tags_only_text_children = {},
source = src,
position = 1,
elements = {},
position_stack = {}
}
setmetatable(inst, { __index = Parser })
return inst
end
--- Advances the parser by COUNT characters.
--- @param count integer? Set to 1 if nil.
function Parser:advance(count)
count = count or 1
self.position = self.position + count
end
--- Checks if the position is out of bounds of the source.
--- @param offset integer? Set to 0 if nil.
function Parser:is_end_of_source(offset)
offset = offset or 0
return self.position + offset > #self.source
end
--- Saves the current position to the position stack.
function Parser:save_position()
table.insert(self.position_stack, self.position)
end
--- Restores the current position to the top of the position stack, and remove
--- that position from the stack.
function Parser:restore_position()
self.position = table.remove(self.position_stack)
end
--- Forgets the top position in the position stack.
function Parser:forget_position()
table.remove(self.position_stack)
end
--- Retreives the character at the current position (plus optional offset).
---
--- @param offset integer? Set to 0 if nil.
--- @return string
function Parser:peek_char(offset)
offset = offset or 0
-- if the offset is out of bound
if self:is_end_of_source(offset) then
return ""
end
return self.source:sub(self.position + offset, self.position + offset)
end
--- Retreives the character at the current position and advance the position.
---
--- @return string
function Parser:get_char()
local char = self:peek_char()
self:advance()
return char
end
--- Checks if the character at the current current position is WANTED. If so,
--- advance the position, and returns true. Do nothing otherwise and returns
--- false.
---
--- @param wanted string The character to check with.
--- @return boolean
function Parser:check_char(wanted)
local char = self:peek_char()
if char == wanted then
self:advance()
return true
end
return false
end
--- Checks if WANTED is present at the current position in the source. If so,
--- advance the position and returns true. Do nothing otherwise and returns
--- false.
---
--- @param wanted string
--- @return boolean
---
function Parser:check_str(wanted)
self:save_position()
-- For each character in WANTED
for i = 1, #wanted do
-- Checks if the character is present
if not self:check_char(wanted:sub(i, i)) then
self:restore_position()
return false
end
end
self:forget_position()
return true
end
--- Checks if the string at the current position matches the given pattern.
--- The pattern is matched for each character in a sequence. Returns the matched
--- string. Advances the position of the parser.
---
--- @param pattern string
--- @return string
---
function Parser:match_pattern(pattern)
local buffer = ""
while not self:is_end_of_source() do
local ch = self:peek_char()
if not ch:match(pattern) then
break
end
self:advance()
buffer = buffer .. ch
end
return buffer
end
--- Tries to parse an emote. Only recognizes emotes present in the `valid_emotes`
--- field of the parser.
---
--- Format of the table :
--- { type = "emote",
--- name = string }
---
--- @return table?
function Parser:parse_emote()
self:save_position()
-- if there is no beginning ":"
if not self:check_char(":") then
self:restore_position()
return nil
end
-- extract the emote name
local name = self:match_pattern(PAT_EMOTE)
-- if there is no ending ":"
if not self:check_char(":") then
self:restore_position()
return nil
end
-- if the emote name isnt valid
if not self.valid_emotes[name] then
self:restore_position()
return nil
end
self:forget_position()
return {
type = "emote",
name = name
}
end
--- Tries to parse a bbcode openning tag. Only recognizes tags present in
--- `valid_bbcode_tags` field of the parser.
---
--- Returns the name of the tag, and its attribute (if any present).
---
--- @return string?, string?
function Parser:parse_bbcode_open()
self:save_position()
-- if there is no beginning "["
if not self:check_char("[") then
self:restore_position()
return nil
end
-- extract the tag name
local name = self:match_pattern(PAT_BBCODE_TAG)
-- if there is no tag name
if name == "" then
self:restore_position()
return nil
end
local attribute = nil
-- if there is an attribute given
if self:check_char("=") then
-- extract it
attribute = self:match_pattern(PAT_BBCODE_ATTR)
end
-- if there is no closing "]"
if not self:check_char("]") then
self:restore_position()
return nil
end
-- if the tag isnt valid
if not self.valid_bbcode_tags[name] then
self:restore_position()
return nil
end
self:forget_position()
return name, attribute
end
--- Tries to parse a bbcode tag. Only recognizes tags present in `valid_bbcode_tags`
--- field of the parser.
---
--- Format of the table :
--- { type = "bbcode",
--- name = string,
--- attribute = string?,
--- children = (string|table)[] }
---
--- @return table?
function Parser:parse_bbcode()
self:save_position()
local name, attribute = self:parse_bbcode_open()
-- if there isnt a open bbcode tag here
if name == nil then
self:restore_position()
return nil
end
local children = {}
-- parse children elements of that tag
while not self:is_end_of_source() do
-- if there is a close tag here
if self:check_str("[/" .. name .. "]") then
break
end
-- if that tag only accept text children
if self.bbcode_tags_only_text_children[name] then
local ch = self:get_char()
if #children == 0 then
table.insert(children, ch)
else
children[1] = children[1] .. ch
end
else
local element = self:parse_element(children)
-- if the end of the source has been reached
if element == nil then
self:restore_position()
return nil
end
table.insert(children, element)
end
end
self:forget_position()
return {
type = "bbcode",
name = name,
attribute = attribute,
children = children
}
end
--- Tries to parse a ruler element.
---
--- Format of the table :
--- { type = "ruler" }
---
--- @return table?
function Parser:parse_ruler()
if not self:check_str("---") then
return nil
end
return {
type = "ruler",
}
end
--- Tries to parse a loose link.
---
--- Format of the table :
--- { type = "link",
--- url = string }
---
--- @return table?
function Parser:parse_link()
self:save_position()
-- we extract a "word" (bunch of printable characters without spaces).
local word = self:match_pattern("%g")
-- if that "word" matches the link pattern
if not word:match(PAT_LINK) then
self:restore_position()
return nil
end
self:forget_position()
return {
type = "link",
url = word,
}
end
--- Tries to parse an element.
---
--- Returns either a table or a string.
--- A string represent simple text.
--- A table represent different kind of element that can be differienciated
--- by its `type` field.
---
--- Valid types : emote, bbcode, link, ruler.
--- Each type has different fields. See `Parser:parse_*` functions for more
--- info.
---
--- Returns nil when the end of the source has been reached.
---
--- @param sibblings (string|table)[]
--- @return (table|string)?
function Parser:parse_element(sibblings)
if self:is_end_of_source() then
return nil
end
local element = self:parse_emote()
or self:parse_bbcode()
or self:parse_ruler()
or self:parse_link()
if element == nil then
if #sibblings > 0 then
local last = sibblings[#sibblings]
if type(last) == "string" then
table.remove(sibblings)
return last .. self:get_char()
end
end
return self:get_char()
end
return element
end
--- Parses the whole source at once, returning all parsed elements.
--- See `Parser:parse_element` for more information about the return value.
---
--- @return (string|table)[]
function Parser:parse()
local elements = {}
while true do
local element = self:parse_element(elements)
if element == nil then
break
end
table.insert(elements, element)
end
return elements
end
return Parser

View File

@ -1,62 +1,150 @@
local babycode = {}
local _escape_html = function(text)
return text:gsub("[&<>\"']", {
["&"] = "&amp;",
["<"] = "&lt;",
[">"] = "&gt;",
['"'] = "&quot;",
["'"] = "&#39;"
})
local string_trim = require("lapis.util").trim
local emoji = require("lib.babycode-emoji")
local Parser = require("lib.babycode-parser")
local function s_split(s, delimiter, max_matches, trim, allow_empty)
local result = {}
if s == "" then
return result
end
trim = trim == nil and true or trim
local tr = function(subj)
if trim then return string_trim(subj) else return subj end
end
max_matches = max_matches or -1
allow_empty = allow_empty == nil and true or allow_empty
if delimiter == "" then
for i=1, #s do
local c = s:sub(i, 1)
if allow_empty or c ~= "" then
table.insert(result, c)
if max_matches > 0 and #result == max_matches then
break
end
end
end
return result
end
local current_pos = 1
local delim_len = #delimiter
while true do
if max_matches > 0 and #result >= max_matches then
break
end
---@diagnostic disable-next-line: param-type-mismatch
local start_pos, end_pos = s:find(delimiter, current_pos, true)
if not start_pos then
break
end
local substr = s:sub(current_pos, start_pos - 1)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
current_pos = end_pos + 1
end
local substr = s:sub(current_pos)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
return result
end
local function list(tag, children)
local list_body = children:gsub(" +\n", "<br>"):gsub("\n\n+", "\1")
local list_items = s_split(list_body, "\1")
local lis = ""
for _, li in ipairs(list_items) do
lis = lis .. "<li>" .. li .. "</li>"
end
return "<" .. tag .. ">" .. lis .. "</" .. tag .. ">"
end
local tags = {
b = "<strong>$S</strong>",
i = "<em>$S</em>",
s = "<del>$S</del>",
img = "<div class=\"post-img-container\"><img class=\"block-img\" src=$A alt=$S></div>",
url = "<a href=\"$A\">$S</a>",
quote = "<blockquote>$S</blockquote>",
code = function(children)
local is_inline = children:match("\n") == nil
if is_inline then
return "<code class=\"inline-code\">" .. children .. "</code>"
else
local t = string_trim(children)
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(t)
return "<pre><span class=\"copy-code-container\">"..button.."</span><code>"..t.."</code></pre>"
end
end,
ul = function(children)
return list("ul", children)
end,
ol = function(children)
return list("ol", children)
end,
}
local text_only = {
code = true,
}
---renders babycode to html
---@param s string input babycode
---@param escape_html fun(s: string): string function that escapes html
function babycode.to_html(s, escape_html)
if not s or s == "" then return "" end
-- extract code blocks first and store them as placeholders
-- don't want to process bbcode embedded into a code block
local code_blocks = {}
local code_count = 0
local text = s:gsub("%[code%](.-)%[/code%]", function(code)
code_count = code_count + 1
code_blocks[code_count] = code
return "\1CODE:"..code_count.."\1"
end)
---@param html_escape fun(s: string): string function to escape html
function babycode.to_html(s, html_escape)
-- normalize line ending chars
local subj = string_trim(html_escape(s)):gsub("\r\n", "\n"):gsub("\r", "\n")
local parser = Parser.new(subj)
parser.valid_bbcode_tags = tags
parser.valid_emotes = emoji
parser.bbcode_tags_only_text_children = text_only
-- replace `[url=https://example.com]Example[/url] tags
text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
return '<a href="'..escape_html(url)..'">'..escape_html(label)..'</a>'
end)
-- replace `[url]https://example.com[/url] tags
text = text:gsub("%[url%]([^%]]+)%[/url%]", function(url)
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
end)
-- bold, italics, strikethrough
text = text:gsub("%[b%](.-)%[/b%]", "<strong>%1</strong>")
text = text:gsub("%[i%](.-)%[/i%]", "<em>%1</em>")
text = text:gsub("%[s%](.-)%[/s%]", "<del>%1</del>")
-- replace loose links
text = text:gsub("(https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/])", function(url)
if not text:find('<a[^>]*>'..url..'</a>') then
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
local elements = parser:parse()
local out = ""
local function fold(element, nobr)
if type(element) == "string" then
if nobr then
return element
end
return url
end)
-- replace code block placeholders back with their original contents
text = text:gsub("\1CODE:(%d+)\1", function(n)
return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>"
end)
-- finally, normalize newlines replace them with <br>
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
return text
return element:gsub(" +\n", "<br>"):gsub("\n\n+", "<br><br>")
end
if element.type == "bbcode" then
local c = ""
for _, child in ipairs(element.children) do
local _nobr = element.name == "code" or element.name == "ul" or element.name == "ol"
c = c .. fold(child, _nobr)
end
local res = ""
if type(tags[element.name]) == "string" then
res = (tags[element.name]):gsub("%$S", c)
if element.attribute then
res = res:gsub("%$A", element.attribute)
end
return res
elseif type(tags[element.name]) == "function" then
res = tags[element.name](c, element.attribute)
end
return res
elseif element.type == "link" then
return "<a href=\""..element.url.."\">"..element.url.."</a>"
elseif element.type == "emote" then
return emoji[element.name]
elseif element.type == "ruler" then
return "<hr>"
end
end
for _, e in ipairs(elements) do
out = out .. fold(e, false)
end
return out
end
return babycode

59
lib/sse.lua Normal file
View File

@ -0,0 +1,59 @@
---@class SSE
---@field active boolean if the stream is not active, you should stop the loop.
---@field private _queue table
local sse = {}
---Construct a new SSE object
---@return SSE
function sse:new()
ngx.header.content_type = "text/event-stream"
ngx.header.cache_control = "no-cache"
ngx.header.connection = "keep-alive"
ngx.status = ngx.HTTP_OK
ngx.flush(true)
local obj = {
active = true,
_queue = {},
}
ngx.on_abort(function()
obj.active = false
end)
return setmetatable(obj, {__index = sse})
end
---add data to the stream, writing on the next dispatch.
---if `event` is given, it will be the key.
---@param data string
---@param event? string
---@return boolean status
function sse:enqueue(data, event)
if not self.active then return false end
table.insert(self._queue, {
data = data,
event = event,
})
return true
end
---send all events since the last dispatch and flush the queue.
---call this every iteration of the loop.
function sse:dispatch()
while #self._queue > 0 do
local msg = table.remove(self._queue, 1)
if msg.event then
ngx.print("event: " .. msg.event .. "\n")
end
ngx.print("data: " .. msg.data .. "\n\n")
end
ngx.flush(true)
end
---close the stream.
function sse:close()
self.active = false
end
return sse

View File

@ -48,4 +48,69 @@ return {
db.query("CREATE INDEX idx_topics_slug ON topics(slug)")
db.query("CREATE INDEX idx_threads_slug ON threads(slug)")
end,
[6] = function ()
schema.drop_column("post_history", "user_id")
end,
[7] = function ()
db.query('DROP INDEX "idx_users_avatar"')
schema.drop_column("users", "avatar_id")
schema.add_column("users", "avatar_id", "REFERENCES avatars(id) DEFAULT 1")
end,
[8] = function ()
schema.add_column("topics", "sort_order", types.integer{default = 0})
db.query("UPDATE topics SET sort_order = (SELECT COUNT(*) FROM topics t2 WHERE t2.ROWID <= topics.ROWID)")
end,
[9] = function ()
schema.add_column("post_history", "original_markup", types.text{null = false})
schema.add_column("post_history", "markup_language", types.text{default = "babycode"})
end,
[10] = function ()
schema.add_column("users", "signature_original_markup", types.text{default = ""})
schema.add_column("users", "signature_rendered", types.text{default = ""})
end,
[11] = function ()
local render = require("lib.babycode").to_html
local html_escape = require("lapis.html").escape
local phs = db.query("SELECT * from post_history")
local users = db.query("SELECT * from users")
db.query("BEGIN")
for _, post_history in ipairs(phs) do
db.query("UPDATE post_history SET content = ? WHERE id = ?", render(post_history.original_markup, html_escape), post_history.id)
end
for _, user in ipairs(users) do
db.query("UPDATE users SET signature_rendered = ? WHERE id = ?", render(user.signature_original_markup, html_escape), user.id)
end
db.query("COMMIT")
end,
[12] = function ()
schema.create_table("api_rate_limits", {
{"id", types.integer{primary_key = true}},
{"method", types.text{null = false}},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
{"logged_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
})
db.query("CREATE INDEX idx_rate_limit_user_method ON api_rate_limits (user_id, method)")
end,
[13] = function ()
schema.create_table("subscriptions", {
{"id", types.integer{primary_key = true}},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
{"last_seen", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)) NOT NULL"},
})
db.query("CREATE INDEX idx_subscription_user_thread ON subscriptions (user_id, thread_id)")
end,
}

View File

@ -12,12 +12,24 @@ function Users_mt:is_admin()
return self.permission == constants.PermissionLevel.ADMIN
end
function Users_mt:is_mod()
return self.permission >= constants.PermissionLevel.MODERATOR
end
function Users_mt:is_system()
return self.permission == constants.PermissionLevel.SYSTEM
end
function Users_mt:is_logged_in_guest()
return self:is_guest() and true
end
function Users_mt:is_default_avatar()
return self.avatar_id == nil
return self.avatar_id == 1
end
function Users_mt:is_logged_in()
return true
end
local ret = {
@ -28,6 +40,7 @@ local ret = {
PostHistory = Model:extend("post_history"),
Sessions = Model:extend("sessions"),
Avatars = Model:extend("avatars"),
Subscriptions = Model:extend("subscriptions"),
}
return ret

View File

@ -19,6 +19,7 @@ http {
lua_code_cache ${{CODE_CACHE}};
location / {
lua_check_client_abort on;
default_type text/html;
content_by_lua_block {
require("lapis").serve("app")
@ -26,16 +27,29 @@ http {
}
location /static/ {
alias static/;
alias data/static/;
}
location /favicon.ico {
alias static/favicon.ico;
alias data/static/favicon.ico;
}
location /avatars {
alias static/avatars;
alias data/static/avatars;
expires 1y;
}
location /emoji {
alias data/static/emoji;
expires 1y;
}
location /static/js/ {
alias js/;
}
location /static/fonts/ {
alias fonts/;
}
}
}

25
porom-dev-1.rockspec Normal file
View File

@ -0,0 +1,25 @@
package = "porom"
version = "dev-1"
source = {
url = "ssh://gitea@git.poto.cafe:222/yagich/porom.git"
}
description = {
summary = "Homegrown forum software",
homepage = "",
license = "CNPLv7+"
}
dependencies = {
"lua ~> 5.1",
"lapis == 1.16.0",
"lsqlite3",
"magick",
"luasodium",
"luaossl",
}
build = {
type = "none"
}

744
sass/style.scss Normal file
View File

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

View File

@ -0,0 +1,3 @@
return {
key = "PROD_SECRET_KEY_HERE",
}

37
start.sh Executable file
View File

@ -0,0 +1,37 @@
#!/bin/bash
set -e
start() {
lapis migrate
lapis serve
}
first_launch() {
echo "Setting up for the first time"
mkdir -p secrets
local SECRET
SECRET="$(openssl rand -hex 32)"
echo "return { key = \"${SECRET}\",}" > secrets/secrets.lua
touch "secrets/.touched.$LAPIS_ENVIRONMENT"
mkdir -p data/db
luajit schema.lua
chmod -R a+rw data
lapis migrate
luajit create_default_accounts.lua
}
if [[ $# -ne 1 ]]; then
export LAPIS_ENVIRONMENT="development"
echo "WARN: no environment passed, assuming default (development)"
else
export LAPIS_ENVIRONMENT="$1"
fi
echo "Starting in $LAPIS_ENVIRONMENT"
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
first_launch
fi
start

5
svg-icons/error.etlua Normal file
View File

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

5
svg-icons/image.etlua Normal file
View File

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

5
svg-icons/info.etlua Normal file
View File

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

5
svg-icons/lock.etlua Normal file
View File

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

5
svg-icons/sticky.etlua Normal file
View File

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

5
svg-icons/warn.etlua Normal file
View File

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

297
util.lua
View File

@ -2,11 +2,14 @@ local util = {}
local magick = require("magick")
local db = require("lapis.db")
local html_escape = require("lapis.html").escape
local constants = require("constants")
local string_trim = require("lapis.util").trim
local Avatars = require("models").Avatars
local Users = require("models").Users
local Posts = require("models").Posts
local PostHistory = require("models").PostHistory
local Threads = require("models").Threads
local babycode = require("lib.babycode")
@ -14,22 +17,161 @@ util.TransientUser = {
is_admin = function (self)
return false
end,
is_mod = function (self)
return false
end,
is_guest = function (self)
return true
end,
is_system = function (self)
return false
end,
is_logged_in_guest = function (self)
return false
end,
is_logged_in = function (self)
return false
end,
username = "Deleted User",
}
-- PURE API
function util.get_user_avatar_url(req, user)
if not user.avatar_id then
return "/avatars/default.webp"
end
return Avatars:find(user.avatar_id).file_path
end
---split a string
---@param s string subject
---@param delimiter string? string to split by, can be empty to split by character
---@param max_matches integer? the maximum number of returned elements
---@param trim boolean? whether to trim whitespace off matches
---@param allow_empty boolean? should empty matches be in the resulting table
---@return string[]
function util.s_split(s, delimiter, max_matches, trim, allow_empty)
local result = {}
if s == "" then
return result
end
trim = trim == nil and true or trim
local tr = function(subj)
if trim then return string_trim(subj) else return subj end
end
max_matches = max_matches or -1
allow_empty = allow_empty == nil and true or allow_empty
if delimiter == "" then
for i=1, #s do
local c = s:sub(i, 1)
if allow_empty or c ~= "" then
table.insert(result, c)
if max_matches > 0 and #result == max_matches then
break
end
end
end
return result
end
local current_pos = 1
local delim_len = #delimiter
while true do
if max_matches > 0 and #result >= max_matches then
break
end
---@diagnostic disable-next-line: param-type-mismatch
local start_pos, end_pos = s:find(delimiter, current_pos, true)
if not start_pos then
break
end
local substr = s:sub(current_pos, start_pos - 1)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
current_pos = end_pos + 1
end
local substr = s:sub(current_pos)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
return result
end
function util.split_sentences(sentences, max_sentences)
return util.s_split(sentences, ".", max_sentences or 2, true, false)
end
---@return string
function util.get_post_url(req, post_id, hash)
hash = hash ~= false
local post = Posts:find({id = post_id})
if not post then return "" end
local thread = Threads:find({id = post.thread_id})
if not thread then return "" end
local url = req:url_for("thread", {slug = thread.slug}, {after = post_id})
if not hash then return url end
return url .. "#post-" .. post_id
end
function util.infobox_message(msg)
local sentences = util.split_sentences(msg)
if #sentences == 1 then
return "<b>" .. sentences[1] .. ". " .. "</b>"
end
return "<span><b>" .. sentences[1] .. ". " .. "</b> " .. sentences[2] .. ".</span>"
end
function util.get_logged_in_user(req)
if req.session.session_key == nil then
return nil
end
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
if #session > 0 then
return Users:find({id = session[1].user_id})
end
return nil
end
function util.get_logged_in_user_or_transient(req)
return util.get_logged_in_user(req) or util.TransientUser
end
function util.ntob(v)
return v ~= 0
end
function util.bton(b)
return 1 and b or 0
end
function util.stob(s)
return s == "true"
end
function util.form_bool_to_sqlite(s)
return util.bton(util.stob(s))
end
function util.is_thread_locked(thread)
return util.ntob(thread.is_locked)
end
function util.is_topic_locked(topic)
return util.ntob(topic.is_locked)
end
-- OTHER API
function util.extend_session_cookie(req)
req.session.last_activity = os.time()
end
function util.validate_and_create_image(input_image, filename)
local img = magick.load_image_from_blob(input_image)
@ -62,49 +204,31 @@ function util.validate_and_create_image(input_image, filename)
return true
end
function util.get_logged_in_user(req)
if req.session.session_key == nil then
return nil
function util.destroy_avatar(avatar_id)
if avatar_id == 1 then
print("won't delete default avatar")
return
end
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
if #session > 0 then
return Users:find({id = session[1].user_id})
local avatar = Avatars:find(avatar_id)
if not avatar then
return
end
return nil
end
function util.get_logged_in_user_or_transient(req)
return util.get_logged_in_user(req) or util.TransientUser
end
function util.ntob(v)
return v ~= 0
end
function util.bton(b)
return 1 and b or 0
end
function util.stob(s)
if s == "true" then
return true
end
if s == "false" then
return false
local file_path = "data/static" .. avatar.file_path
local f = io.open(file_path, "r")
if not f then
print("can't open avatar file")
else
f:close()
os.remove(file_path)
avatar:delete()
end
end
function util.form_bool_to_sqlite(s)
return util.bton(util.stob(s))
end
function util.is_thread_locked(thread)
return util.ntob(thread.is_locked)
end
function util.create_post(thread_id, user_id, content)
function util.create_post(thread_id, user_id, content, markup_language)
markup_language = markup_language or "babycode"
db.query("BEGIN")
local post = Posts:create({
thread_id = thread_id,
@ -112,13 +236,17 @@ function util.create_post(thread_id, user_id, content)
current_revision_id = db.NULL,
})
local bb_content = babycode.to_html(content, html_escape)
local parsed_content = ""
if markup_language == "babycode" then
parsed_content = babycode.to_html(content, html_escape)
end
local revision = PostHistory:create({
post_id = post.id,
user_id = user_id,
content = bb_content,
content = parsed_content,
is_initial_revision = true,
original_markup = content,
markup_language = "babycode",
})
post:update({current_revision_id = revision.id})
@ -127,4 +255,89 @@ function util.create_post(thread_id, user_id, content)
return post
end
function util.update_post(post, new_content, markup_language)
markup_language = markup_language or "babycode"
db.query("BEGIN")
local parsed_content = ""
if markup_language == "babycode" then
parsed_content = babycode.to_html(new_content, html_escape)
end
local revision = PostHistory:create({
post_id = post.id,
content = parsed_content,
is_initial_revision = false,
original_markup = new_content,
markup_language = markup_language
})
post:update({current_revision_id = revision.id})
db.query("COMMIT")
end
function util.transfer_and_delete_user(user)
local deleted_user = Users:find({
username = "DeletedUser",
})
-- this needs to be atomic
db.query("BEGIN")
db.query('UPDATE "threads" SET "user_id" = ? WHERE "user_id" = ?', deleted_user.id, user.id)
db.query('UPDATE "posts" SET "user_id" = ? WHERE "user_id" = ?', deleted_user.id, user.id)
user:delete() -- uncomment later
db.query("COMMIT")
end
function util.pop_infobox(req)
if not req.session.infobox then return end
req.infobox = req.session.infobox
req.session.infobox = nil
end
function util.inject_infobox(req, message, kind)
kind = kind or constants.InfoboxKind.INFO
local ib = {
msg = message,
kind = kind,
}
req.session.infobox = ib
end
function util.inject_err_infobox(req, message)
local ib = {
msg = message,
kind = constants.InfoboxKind.ERROR,
}
req.session.infobox = ib
end
function util.inject_warn_infobox(req, message)
local ib = {
msg = message,
kind = constants.InfoboxKind.WARN,
}
req.session.infobox = ib
end
function util.rate_limit_allowed(user_id, method, seconds)
local last_call = db.query([[
SELECT logged_at FROM api_rate_limits
WHERE user_id = ? AND method = ?
ORDER BY logged_at DESC LIMIT 1
]], user_id, method)
if #last_call == 0 or (os.time() - last_call[1].logged_at) >= seconds then
db.query(
"DELETE FROM api_rate_limits WHERE user_id = ? AND method = ?",
user_id, method
)
db.query(
"INSERT INTO api_rate_limits (user_id, method) VALUES (?, ?)",
user_id, method
)
return true
else
return false
end
end
return util

192
views/babycode.etlua Normal file
View File

@ -0,0 +1,192 @@
<div class=darkbg>
<h1 class="thread-title">Babycode guide</h1>
</div>
<% local tocs = {} %>
<div class="babycode-guide-container">
<div class="guide-topics">
<section class="babycode-guide-section">
<h2 id="what-is-babycode">What is babycode?</h2>
<% table.insert(tocs, {"What is babycode?", "what-is-babycode"}) %>
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
</section>
<section class="babycode-guide-section">
<h2 id="text-formatting-tags">Text formatting tags</h2>
<% table.insert(tocs, {"Text formatting tags", "text-formatting-tags"}) %>
<ul>
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
[b]Hello World[/b]<br>
Will become<br>
<strong>Hello World</strong>
</ul>
<ul>
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
[i]Hello World[/i]<br>
Will become<br>
<em>Hello World</em>
</ul>
<ul>
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
[s]Hello World[/s]<br>
Will become<br>
<del>Hello World</del>
</ul>
</section>
<section class="babycode-guide-section">
<h2 id="emoji">Emotes</h2>
<% table.insert(tocs, {"Emotes", "emoji"}) %>
<p>There are a few emoji in the style of old forum emotes:</p>
<% --[[ we'll pretend like i will totally refactor emojis and generate this table dynamically in the future. clown emoji ]]%>
<table class="emoji-table">
<tr>
<th>Short code(s)</th>
<th>Emoji result</th>
</tr>
<tr>
<td>:angry:</td>
<td><img class=emoji src="/emoji/angry.png" alt="angry" title=":angry:"></td>
</tr>
<tr>
<td>:(:</td>
<td><img class=emoji src="/emoji/frown.png" alt="frown" title=":(:"></td>
</tr>
<tr>
<td>:D:</td>
<td><img class=emoji src="/emoji/grin.png" alt="grin" title=":D:"></td>
</tr>
<tr>
<td>:imp:</td>
<td><img class=emoji src="/emoji/imp.png" alt="imp" title=":imp:"></td>
</tr>
<tr>
<td>:impangry: :angryimp:</td>
<td><img class=emoji src="/emoji/impangry.png" alt="impangry" title=":impangry:"></td>
</tr>
<tr>
<td>:lobster:</td>
<td><img class=emoji src="/emoji/lobster.png" alt="lobster" title=":lobster:"></td>
</tr>
<tr>
<td>:|:</td>
<td><img class=emoji src="/emoji/neutral.png" alt="neutral" title=":|:"></td>
</tr>
<tr>
<td>:pensive:</td>
<td><img class=emoji src="/emoji/pensive.png" alt="pensive" title=":pensive:"></td>
</tr>
<tr>
<td>:):</td>
<td><img class=emoji src="/emoji/smile.png" alt="smile" title=":):"></td>
</tr>
<tr>
<td>:smiletear: :crytear:</td>
<td><img class=emoji src="/emoji/smiletear.png" alt="smiletear" title=":smiletear:"></td>
</tr>
<tr>
<td>:,: :T: :cry: :sob:</td>
<td><img class=emoji src="/emoji/sob.png" alt="sob" title=":sob:"></td>
</tr>
<tr>
<td>:o: :O:</td>
<td><img class=emoji src="/emoji/surprised.png" alt="surprised" title=":o:"></td>
</tr>
<tr>
<td>:hmm: :think: :thinking:</td>
<td><img class=emoji src="/emoji/think.png" alt="think" title=":think:"></td>
</tr>
<tr>
<td>:P: :p:</td>
<td><img class=emoji src="/emoji/tongue.png" alt="tongue" title=":p:"></td>
</tr>
<tr>
<td>:weary:</td>
<td><img class=emoji src="/emoji/weary.png" alt="weary" title=":weary:"></td>
</tr>
<tr>
<td>:;: :wink:</td>
<td><img class=emoji src="/emoji/wink.png" alt="wink" title=":wink:"></td>
</tr>
</table>
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
</section>
<section class="babycode-guide-section">
<h2 id="paragraph-rules">Paragraph rules</h2>
<% table.insert(tocs, {"Paragraph rules", "paragraph-rules"}) %>
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
paragraph 2">Copy</button></span><code>paragraph 1
paragraph 2</code></pre>
Will produce:<br>
paragraph 1<br><br>paragraph 2
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
still paragraph 1">Copy</button></span><code>paragraph 1
still paragraph 1</code></pre>
That will produce:<br>
paragraph 1<br>still paragraph 1
</section>
<section class="babycode-guide-section">
<h2 id="links">Links</h2>
<% table.insert(tocs, {"Links", "links"}) %>
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
<a href="https://example.com">Link label</a></p>
</section>
<section class="babycode-guide-section">
<h2 id="attaching-an-image">Attaching an image</h2>
<% table.insert(tocs, {"Attaching an image", "attaching-an-image"}) %>
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Lua logo with a cowboy hat[/img]</code>
<div class="post-img-container"><img class="block-img" src="/avatars/default.webp" alt="the Lua logo with a cowboy hat"></div></p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
</section>
<section class="babycode-guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
<% table.insert(tocs, {"Adding code blocks", "adding-code-blocks"}) %>
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
<% local code = 'func _ready() -> void:\n\tprint("hello world!")' %>
<pre><span class="copy-code-container"><button type=button class="copy-code" value="<%= code %>">Copy</button></span><code><%= code %></code></pre>
<p>Babycodes are not parsed inside code blocks.</p>
</section>
<section class="babycode-guide-section">
<h2 id="quoting">Quoting</h2>
<% table.insert(tocs, {"Quoting", "quoting"}) %>
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
</section>
<section class="babycode-guide-section">
<h2 id="lists">Lists</h2>
<% table.insert(tocs, {"Lists", "lists"}) %>
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
<pre><span class="copy-code-container"><button type=button class="copy-code" value="[ul]
item 1
item 2
item 3
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
[/ul]">Copy</button></span><code>[ul]
item 1
item 2
item 3
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
[/ul]</code></pre><br>
Will produce the following list:
<ul>
<li>item 1</li>
<li>item 2</li>
<li>item 3<br>still item 3 (break line without inserting a new item by using two spaces at the end of a line)</li>
</ul>
</section>
</div>
<div class="guide-toc">
<h2>Table of contents</h2>
<ul>
<% for _, t in ipairs(tocs) do %>
<li><a href="#<%= t[2] %>"><%= t[1] %></a></li>
<% end %>
</ul>
</div>
</div>

View File

@ -2,9 +2,22 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<% if page_title then %>
<title>Porom - <%= page_title %></title>
<% else %>
<title>Porom</title>
<% end %>
<link rel="stylesheet" href="<%= "/static/style.css?v=" .. __cachebust %>">
</head>
<body>
<% render("views.common.topnav") -%>
<% content_for("inner") %>
<footer class="darkbg">
<span>Porom commit <a href="<%= "https://git.poto.cafe/yagich/porom/commit/" .. __commit %>"><%= __commit %></a>
</span>
</footer>
<script src="/static/js/copy-code.js"></script>
<script src="/static/js/date-fmt.js"></script>
<script src="/static/js/ui.js?v=2"></script>
</body>
</html>

View File

@ -0,0 +1,25 @@
<div class="babycode-editor-container">
<div class="tab-buttons">
<button type=button class="tab-button active" data-target-id="tab-edit">Write</button>
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button>
</div>
<div class="tab-content active" id="tab-edit">
<span class="babycode-button-container">
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button>
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><% render("svg-icons.image") %></button>
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button>
</span>
<textarea class="babycode-editor" name="<%= ta_name %>" id="babycode-content" placeholder="<%= ta_placeholder or "Post body"%>" <%= not optional and "required" or "" %>><%- prefill or "" %></textarea>
<a href="<%= url_for("babycode_guide") %>" target="_blank">babycode guide</a>
</div>
<div class="tab-content" id="tab-preview">
<div id="babycode-preview-errors-container">Type something!</div>
<div id="babycode-preview-container"></div>
</div>
</div>
<script src="/static/js/babycode-editor.js?v=1"></script>

View File

@ -0,0 +1,21 @@
<%
local save_button_text = "Post reply"
if cancel_url then
save_button_text = "Save"
end
%>
<form class="post-edit-form" method="post" action="<%= url or "" %>">
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
<% if not cancel_url then %>
<span>
<input type="checkbox" id="subscribe" name="subscribe" <%= session.subscribe_by_default and "checked" or "" %>>
<label for="subscribe">Subscribe to thread</label>
</span>
<% end %>
<span>
<input type=submit value="<%= save_button_text %>">
<% if cancel_url then %>
<a class="linkbutton warn" href="<%= cancel_url %>">Cancel</a>
<% end %>
</span>
</form>

View File

@ -0,0 +1,14 @@
<%
local class = "infobox " .. constants.InfoboxHTMLClass[kind]
local icon = constants.InfoboxIcons[kind]
local sentences = infobox_message(msg)
%>
<div class="<%= class %>">
<span>
<div class="infobox-icon-container">
<% render(icon) %>
</div>
<%- sentences %>
</span>
</div>

View File

@ -0,0 +1,27 @@
<% local left_start = math.max(1, current_page - 5) %>
<% local right_end = math.min(page_count, current_page + 5) %>
<div class="pager">
<span>Page:</span>
<% if current_page > 5 then %>
<a href="?page=1" class="pagebutton">1</a>
<% if left_start > 2 then %>
<span class="currentpage">&hellip;</span>
<% end %>
<% end %>
<% for i = left_start, current_page - 1 do%>
<a href="?page=<%= i %>" class="pagebutton"><%= i %></a>
<% end %>
<% if page_count > 0 then %>
<span class="currentpage"><%= current_page %></span>
<% end %>
<% for i = current_page + 1, right_end do %>
<a href="?page=<%= i %>" class="pagebutton"><%= i %></a>
<% end %>
<% if right_end < page_count then %>
<% if right_end < page_count - 1 then %>
<span class="currentpage">&hellip;</span>
<% end %>
<a href="?page=<%= page_count %>" class="pagebutton"><%= page_count %></a>
<% end %>
</div>

View File

@ -0,0 +1 @@
<span class="timestamp" data-utc="<%= timestamp %>"><%= os.date("%c", timestamp) %></span>

22
views/common/topnav.etlua Normal file
View File

@ -0,0 +1,22 @@
<nav id="topnav">
<span>
<% local topics_url = url_for("all_topics") %>
<a class="site-title" href="<%= topics_url %>">Porom</a>
<a href="<%= topics_url %>">All topics</a>
</span>
<span>
<% if me and me:is_logged_in() then -%>
Welcome, <a href="<%= url_for("user", {username = me.username}) %>"><%= me.username %></a>
&bullet;
<a href="<%= url_for("user_settings", {username = me.username}) %>">Settings</a>
&bullet;
<a href="<%= url_for("user_inbox", {username = me.username}) %>">Inbox</a>
<% if me:is_mod() then %>
&bullet;
<a href="<%= url_for("user_list") %>">User list</a>
<% end %>
<% else -%>
Welcome, guest. Please <a href="<%= url_for("user_signup") %>">sign up</a> or <a href="<%= url_for("user_login") %>">log in</a>
<% end -%>
</span>
</nav>

View File

@ -0,0 +1,19 @@
<div class="darkbg settings-container">
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<h1>Change topics order</h1>
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
<form method="post" id=topics-container>
<% for _, topic in ipairs(topics) do %>
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
<div class="thread-title"><%= topic.name %></div>
<div><%= topic.description %></div>
<input type="hidden" name="<%= topic.id %>" value="<%= topic.sort_order %>" class="topic-input">
</div>
<% end %>
<input type=submit value="Save order">
</form>
</div>
<script src="/static/js/sort-topics.js"></script>

View File

@ -0,0 +1,8 @@
<div class="darkbg settings-container">
<h1>All users</h1>
<ul>
<% for _, user in ipairs(users) do %>
<li><a href="<%= url_for("user", {username = user.username}) %>"><%= user.username %></a></li>
<% end %>
</ul>
</div>

View File

@ -0,0 +1,17 @@
<% for i = #prev_context, 1, -1 do %>
<% local post = prev_context[i] %>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<% end %>
<span class="context-explain">
<span>&uarr;&uarr;&uarr;</span><i>Context</i><span>&uarr;&uarr;&uarr;</span>
</span>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<% render("views.threads.post", {post = editing_post, edit = true, is_latest = false, no_reply = true}) %>
<span class="context-explain">
<span>&darr;&darr;&darr;</span><i>Context</i><span>&darr;&darr;&darr;</span>
</span>
<% for _, post in ipairs(next_context) do %>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<% end %>

View File

@ -0,0 +1,9 @@
<% if not post then %>
<% render("views.common.infobox", {kind = constants.InfoboxKind.ERROR, msg = "Post not found"}) %>
<% else %>
<div class=darkbg>
<h1 class=thread-title><%= post.username .. "'s post in " .. thread.title %></h1>
</div>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<a class=linkbutton href="<%= url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id %>">View in context</a>
<% end %>

View File

@ -1,13 +1,16 @@
<div class="darkbg settings-container">
<h1>New thread</h1>
<form method="post">
<label for="topic_id">Topic:</label>
<label for="topic_id">Topic</label>
<select name="topic_id", id="topic_id" autocomplete="off">
<% for _, topic in ipairs(all_topics) do %>
<option value="<%= topic.id %>" <%- params.topic_id == tostring(topic.id) and "selected" or "" %>><%= topic.name %></value>
<% end %>
</select><br>
<label for="title">Thread title:</label>
<input type="text" id="title" name="title" required><br>
<textarea id="initial_post" name="initial_post" placeholder="Post body" required></textarea><br>
<label for="title">Thread title</label>
<input type="text" id="title" name="title" placeholder="Required" required>
<label for="initial_post">Post body</label><br>
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %>
<input type="submit" value="Create thread">
</form>
</div>

View File

@ -0,0 +1,10 @@
<div id="new-post-notification" class="new-concept-notification hidden">
<div class="new-notification-content">
<p>New post in thread!</p>
<span class="notification-buttons">
<button id="dismiss-new-post-button">Dismiss</button>
<a class="linkbutton" id="go-to-new-post-button">View post</a>
<button id="unsub-new-post-button">Stop updates</button>
</span>
</div>
</div>

85
views/threads/post.etlua Normal file
View File

@ -0,0 +1,85 @@
<%
local pc = "post"
if edit then
pc = pc .. " editing"
end
%>
<div class="<%= pc %>" id="post-<%= post.id %>">
<div class="usercard">
<div class="usercard-inner">
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
<img src="<%= post.avatar_path %>" class="avatar">
</a>
<a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a>
<% if post.status ~= "" then %>
<em class="user-status"><%= post.status %></em>
<% end %>
</div>
</div>
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
<div class="post-info">
<%
--local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
local post_url = get_post_url(post.id)
%>
<a href="<%= post_url %>" title="Permalink"><i>
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
<% else -%>
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
<% end -%>
</i></a>
<span>
<%
local show_edit = me.id == post.user_id and not me:is_guest() and (not ntob(thread.is_locked) or me:is_mod()) and not no_reply
if show_edit then
%>
<a class="linkbutton" href="<%= url_for("edit_post", {post_id = post.id}) .. "#babycode-content" %>">Edit</a>
<% end %>
<%
local show_reply = true
if ntob(thread.is_locked) and not me:is_mod() then
show_reply = false
elseif me:is_guest() then
show_reply = false
elseif edit then
show_reply = false
elseif no_reply then
show_reply = false
end
if show_reply then
local d = post.created_at < post.edited_at and post.edited_at or post.created_at
local quote_src_text = ("[url=%s]%s said:[/url]"):format(
post_url, post.username
)
local reply_text = ("%s\n[quote]%s[/quote]\n"):format(quote_src_text, post.original_markup)
%>
<button value="<%= reply_text %>" class="reply-button">Quote</button>
<% end %>
<%
local show_delete = ((post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()) and not no_reply
if show_delete then
%>
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
<% end %>
</span>
</div>
<div class="post-content">
<% if not edit then %>
<div class="post-inner"><%- post.content %></div>
<% if render_sig and #post.signature_rendered > 0 then %>
<div class="signature-container">
<hr>
<%- post.signature_rendered %></div>
<% end %>
<% else %>
<% render("views.common.babycode-editor", {
cancel_url = url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id,
prefill = post.original_markup,
ta_name = "new_content"
}) %>
<% end %>
</div>
</div>
</div>

View File

@ -1,20 +1,76 @@
<% for _, post in ipairs(posts) do %>
<div>
<img src="<%= post.avatar_path or "/avatars/default.webp" %>">
<div><%= post.username %></div>
<div><p><%- post.content %></p></div>
</div>
<%
local is_locked = ntob(thread.is_locked)
local is_stickied = ntob(thread.is_stickied)
local can_post = (not is_locked and not me:is_guest()) or me:is_mod()
local can_lock = me.id == thread.user_id or me:is_mod()
%>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<% if not user:is_guest() then %>
<h1>Respond to "<%= thread.title %>"</h1>
<form method="post">
<textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br>
<input type="submit" value="Reply">
<main>
<nav class="darkbg">
<h1 class="thread-title"><%= thread.title %></h1>
<span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a>
<% if is_stickied then %> &bullet; <i>stickied, so it's probably important</i>
<% end %>
</span>
<div>
<% if me:is_logged_in() then %>
<form class="modform" action="<%= url_for("thread_subscribe", {slug = thread.slug}) %>" method="post">
<input type="hidden" name="last_visible_post" value=<%= posts[#posts].id %>>
<input type="hidden" name="subscribe" value=<%= is_subscribed and "unsubscribe" or "subscribe" %>>
<input type="submit" value="<%= is_subscribed and "Unsubscribe" or "Subscribe" %>">
</form>
<% end %>
<% if next_cursor then %>
<a href="<%= url_for('thread', {slug = thread.slug}, {cursor = next_cursor}) %>">
Older posts →
</a>
<% if can_lock then %>
<form class="modform" action="<%= url_for("thread_lock", {slug = thread.slug}) %>" method="post">
<input type=hidden value="<%= not is_locked %>" name="target_op">
<input class="warn" type="submit" value="<%= is_locked and "Unlock thread" or "Lock thread" %>">
</form>
<% if me:is_mod() then %>
<form class="modform" action="<%= url_for("thread_sticky", {slug = thread.slug}) %>" method="post">
<input type=hidden value="<%= not is_stickied %>" name="target_op">
<input class="warn" type="submit" value="<%= is_stickied and "Unsticky thread" or "Sticky thread" %>">
</form>
<form class="modform" action="<%= url_for("thread_move", {slug = thread.slug}) %>" method="post">
<label for="new_topic_id">Move to topic:</label>
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
<% for _, topic in ipairs(other_topics) do %>
<option value="<%= topic.id %>" <%- thread.topic_id == topic.id and "selected disabled" or "" %>><%= topic.name %></option>
<% end %>
</select>
<input class="warn" type="submit" value="Move thread">
</form>
<% end %>
<% end %>
</div>
</nav>
<% for i, post in ipairs(posts) do %>
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
<% end %>
</main>
<nav id="bottomnav">
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
</nav>
<% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
<% end -%>
<% if can_post then %>
<h1>Respond to "<%= thread.title %>"</h1>
<% render("views.common.babycode-editor", {ta_name="post_content"}) %>
<% end %>
<dialog id="delete-dialog">
<div class=delete-dialog-inner>
Are you sure you want to delete the highlighted post?
<span>
<button id=post-delete-dialog-close>Cancel</button>
<button class="critical" form=post-delete-form>Delete</button>
<form id="post-delete-form" method="post"></form>
</span>
</div>
</dialog>
<input type="hidden" id="thread-subscribe-endpoint" value="<%= url_for("api_get_thread_updates", {thread_id = thread.id}, {since = os.time()}) %>">
<% render("views.threads.new-post-notification") %>
<script src="/static/js/thread.js?v=1"></script>

View File

@ -1,6 +1,10 @@
<div class="darkbg settings-container">
<h1>Create topic</h1>
<form method="post">
<input type="text" name="name" id="name" placeholder="Topic name" required><br>
<textarea id="description" name="description" placeholder="Topic description" required></textarea><br>
<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>

View File

@ -1,12 +1,12 @@
<div class="darkbg settings-container">
<h1>Editing topic <%= topic.name %></h1>
<form method="post">
<input type="text" name="name" id="name" value="<%= topic.name %>" placeholder="Topic name" required><br>
<textarea id="description" name="description" value="<%= topic.description %>" placeholder="Topic description"></textarea><br>
<input type="checkbox" id="is_locked" name="is_locked" value="<%= ntob(topic.is_locked) %>">
<label for="is_locked">Locked</label><br>
<label for=name>Name</label>
<input type="text" name="name" id="name" value="<%= topic.name %>" placeholder="Topic name" required>
<label for=description>Description</label>
<textarea id="description" name="description" placeholder="Topic description" rows=4><%= topic.description %></textarea>
<input type="submit" value="Save changes">
</form>
<form method="get" action="<%= url_for("topic", {slug = topic.slug}) %>">
<input type="submit" value="Cancel">
</form>
<a class="linkbutton" href="<%= url_for("topic", {slug = topic.slug}) %>">Cancel</a><br>
<i>Note: to preserve history, you cannot change the topic URL.</i>
</form>
</div>

View File

@ -1,25 +1,85 @@
<h1><%= topic.name %></h1>
<h2><%= topic.description %></h2>
<% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<% local is_locked = ntob(topic.is_locked) %>
<nav class="darkbg">
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
<span><%= topic.description %></span>
<div>
<% if thread_create_error == ThreadCreateError.OK then %>
<a href=<%= url_for("thread_create", nil, {topic_id = topic.id}) %>>New thread</a>
<a class="linkbutton" href=<%= url_for("thread_create", nil, {topic_id = topic.id}) %>>New thread</a>
<% elseif thread_create_error == ThreadCreateError.GUEST then %>
<p>Your account is still pending confirmation by an administrator. You are not able to create a new thread or post at this time.</p>
<p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %>
<p>Only logged in users can create threads. <a href="<%= url_for("user_signup") %>">Sign up</a> or <a href="<%= url_for("user_login")%>">log in</a> to create a thread.</p>
<% end %>
<% if me:is_mod() then %>
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
<form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
<input type="hidden" name="is_locked" value="<%= not is_locked %>">
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>">
</form>
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
<% end %>
</div>
</nav>
<% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This topic is locked. Only moderators can create new threads."}) %>
<% end -%>
<% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p>
<% else %>
<p>This topic is locked.</p>
<% for _, thread in ipairs(threads_list) do %>
<% local is_stickied = ntob(thread.is_stickied) %>
<% local thread_is_locked = ntob(thread.is_locked) %>
<div class="thread">
<div class="thread-sticky-container contain-svg">
<% if is_stickied then -%>
<% render("svg-icons.sticky") %>
<i>Stickied</i>
<% end -%>
</div>
<div class="thread-info-container">
<span>
<span class="thread-title"><a href="<%= url_for("thread", {slug = thread.slug}) %>"><%= thread.title %></a></span>
&bullet;
Started by <a href=<%= url_for("user", {username = thread.started_by}) %>><%= thread.started_by %></a>
on <% render("views.common.timestamp", {timestamp = thread.created_at}) -%>
</span>
<span>
Latest post by <a href="<%= url_for("user", {username = thread.latest_post_username}) %>"><%= thread.latest_post_username %></a>
<a href="<%= url_for("thread", {slug = thread.slug}, {after = thread.latest_post_id}) .. "#post-" .. thread.latest_post_id %>">on <% render("views.common.timestamp", {timestamp = thread.latest_post_created_at}) -%></a>:
</span>
<span class="thread-info-post-preview">
<%- thread.latest_post_content %>
</span>
</div>
<div class="thread-locked-container contain-svg">
<% if thread_is_locked then -%>
<% render("svg-icons.lock") %>
<i>Locked</i>
<% end -%>
</div>
</div>
<% end %>
<% end %>
<% if user:is_admin() then %>
<br>
<a href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
<form method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
<input type="hidden" name="is_locked" value="<%= not ntob(topic.is_locked) %>">
<p><%= "This topic is " .. (ntob(topic.is_locked) and "" or "un") .. "locked." %></p>
<input type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock" or "Lock" %>">
</form>
<% end %>
<nav id="bottomnav">
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
</nav>
<dialog id="delete-dialog">
<div class=delete-dialog-inner>
Are you sure you want to delete this topic?
<span>
<button id=topic-delete-dialog-close>Cancel</button>
<button class="critical" form=topic-delete-form>Delete</button>
<form id="topic-delete-form" method="post" action="<%= url_for("topic_delete", {slug = topic.slug}) %>"></form>
</span>
</div>
</dialog>
<script src="/static/js/topic.js"></script>

View File

@ -1,16 +1,44 @@
<h1>Topics</h1>
<nav class="darkbg">
<h1 class="thread-title">All topics</h1>
<% if me:is_mod() then %>
<a class="linkbutton" href="<%= url_for("topic_create") %>">Create new topic</a>
<a class="linkbutton" href="<%= url_for("sort_topics") %>">Sort topics</a>
<% end %>
</nav>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<% if #topic_list == 0 then %>
<p>There are no topics.</p>
<% else %>
<ul>
<% for i, v in ipairs(topic_list) do %>
<li>
<a href=<%= url_for("topic", {slug = v.slug}) %>><%= v.name %></a> - <%= v.description %>
</li>
<% for _, topic in ipairs(topic_list) do %>
<% local is_locked = ntob(topic.is_locked) %>
<div class="topic">
<div class="topic-info-container">
<a class="thread-title" href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
<%= topic.description %>
<% if topic.latest_thread_username then %>
<span>
Latest thread: <a href="<%= url_for("thread", {slug = topic.latest_thread_slug}) %>"><%= topic.latest_thread_title %></a> by <a href="<%= url_for("user", {username = topic.latest_thread_username}) %>"><%= topic.latest_thread_username %></a> on <% render("views.common.timestamp", {timestamp = topic.latest_thread_created_at}) -%>
</span>
<% if active_threads[topic.id] then %>
<% local thread = active_threads[topic.id] %>
<span>
Latest post in: <a href="<%= url_for("thread", {slug = thread.thread_slug}) %>"><%= thread.thread_title %></a> by <a href="<%= url_for("user", {username = thread.username}) %>"><%= thread.username %></a> at <a href="<%= get_post_url(thread.post_id) %>"><% render("views.common.timestamp", {timestamp = thread.post_created_at}) -%></a>
</span>
<% end %>
<% else %>
<i>No threads yet.</i>
<% end %>
</div>
<div class="topic-locked-container contain-svg">
<% if is_locked then -%>
<% render("svg-icons.lock") %>
<i>Locked</i>
<% end -%>
</div>
</div>
<% end %>
<% end %>
</ul>
<% if user:is_admin() then %>
<a href="<%= url_for("topic_create") %>">Create new topic</a>
<% end %>

View File

@ -0,0 +1,14 @@
<div class="darkbg settings-container">
<h1>Are you sure you want to delete your account, <%= me.username %>?</h1>
<p>This cannot be undone. This will not delete your posts, only anonymize them.</p>
<p>If you are sure, please type your password below.</p>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<form method="post" action="<%= url_for("user_delete", {username = me.username}) %>">
<input type="password" name="password" id="password" autocomplete="current-password" placeholder="Password" required><br>
<input class="critical" type="submit" value="Delete my account (NO UNDO)">
</form>
</div>

50
views/user/inbox.etlua Normal file
View File

@ -0,0 +1,50 @@
<div class="darkbg">
<h1 class="thread-title">Inbox</h1>
</div>
<div class="inbox-container">
<% if not all_subscriptions then %>
You have no subscriptions.<br>
<% else %>
Your subscriptions:
<ul>
<% for _, sub in ipairs(all_subscriptions) do %>
<li><a href="<%= url_for("thread", {slug = sub.thread_slug}) %>"><%= sub.thread_title %></a>
<form class="modform" method="post" action="<%= url_for("thread_subscribe", {slug = sub.thread_slug}) %>">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</li>
<% end %>
</ul>
<% end %>
<% if #new_posts == 0 then %>
You have no unread posts.
<% else %>
You have <%= total_unreads_count %> unread post<%= total_unreads_count > 1 and "s" or "" %>:
<% for _, thread in ipairs(new_posts) do %>
<div class="accordion">
<div class="accordion-header">
<button type="button" class="accordion-toggle">▼</button>
<% local latest_post_id = thread.posts[#thread.posts].id %>
<%
local unread_posts_text = " (" .. thread.unread_count .. " unread post" .. (thread.unread_count > 1 and "s" or "")-- .. ")"
%>
<a class="accordion-title" href="<%= url_for("thread", {slug = thread.thread_slug}, {after = latest_post_id}) .. "#post-" .. latest_post_id %>" title="Jump to latest post"><%= thread.thread_title .. unread_posts_text %>, latest at <% render("views.common.timestamp", {timestamp = thread.newest_post_time}) -%>)</a>
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark Thread as Read">
</form>
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</div>
<div class="accordion-content">
<% for _, post in ipairs(thread.posts) do %>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true, thread = get_thread_by_id(thread.thread_id)}) %>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>

View File

@ -1,7 +1,7 @@
<div class="darkbg login-container">
<h1>Log In</h1>
<% if err then %>
<h2><%= err %></h2>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<form method="post" action="<%= url_for('user_login') %>" enctype="multipart/form-data">
<label for="username">Username</label><br>
@ -10,3 +10,4 @@
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
<input type="submit" value="Log in">
</form>
</div>

View File

@ -1,18 +1,43 @@
<% local disable_avatar = me:is_logged_in_guest() %>
<div class="darkbg settings-container">
<h1>User settings</h1>
<% if flash_msg then %>
<h2><%= flash_msg %></h2>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<form method="post" action="<%= url_for("user_set_avatar", {username = user.username}) %>" enctype="multipart/form-data">
<img src="<%= avatar_url(user) %>"><br>
<input type="file" name="avatar" accept="image/*"><br>
<input type="submit" value="Update avatar">
<% if not user:is_default_avatar() then %>
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = user.username}) %>">
<form class="avatar-form" method="post" action="<%= url_for("user_set_avatar", {username = me.username}) %>" enctype="multipart/form-data">
<img src="<%= avatar_url(me) %>">
<input id="file" type="file" name="avatar" accept="image/*" required>
<div>
<input type="submit" value="Upload avatar" <%= disable_avatar and "disabled=disabled" %>>
<% if not me:is_default_avatar() then %>
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
<% end %>
<br>
</div>
</form>
<form method="post" action="">
<label for="topic_sort_by">Sort threads by:</label>
<select id="topic_sort_by" name="topic_sort_by">
<option value="activity" <%= session.sort_by == "activity" and "selected" %>>Latest activity</option>
<option value="thread" <%= session.sort_by == "thread" and "selected" %>>Thread creation date</option>
</select>
<label for="status">Status</label>
<input type="text" id="status" name="status" value="<%= user.status %>" maxlength="10"><br>
<input type="submit" value="Save">
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="70" placeholder="Will be shown under your username. Max 70 characters">
<label for="babycode-content">Signature</label><br>
<% render("views.common.babycode-editor-component", {ta_name = "signature", prefill = me.signature_original_markup, ta_placeholder = "Will be shown under each of your posts", optional = true}) %>
<input autocomplete="off" type="checkbox" id="subscribe_by_default" name="subscribe_by_default" <%= session.subscribe_by_default and "checked" or "" %>>
<label for="subscribe_by_default">Subscribe to thread by default when responding</label><br>
<input type="submit" value="Save settings">
</form>
<form method="post" action="<%= url_for("user_change_password", {username = me.username}) %>">
<label for="new_password">Change password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
<% if not me:is_admin() then %>
<div>
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
</div>
<% end %>
</div>

View File

@ -1,7 +1,7 @@
<div class="darkbg login-container">
<h1>Sign up</h1>
<% if err then %>
<h2><%= err %></h2>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<form method="post" action="<%= url_for('user_signup') %>" enctype="multipart/form-data">
<label for="username">Username</label><br>
@ -12,4 +12,5 @@
<input type="password" id="password2" name="password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input type="submit" value="Sign up">
</form>
<p>After you sign up, an administrator will need to confirm your account before you will be allowed to post.</p>
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</p>
</div>

View File

@ -1,21 +1,90 @@
<% if just_logged_in then %>
<h1>Logged in successfully.</h1>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<img src="<%= avatar_url(user) %>">
<h1><%= user.username %></h1>
<% if user:is_guest() and user_is_me then %>
<h2>You are a guest. An administrator needs to approve your account before you will be able to post.</h2>
<% end %>
<% if user_is_me then %>
<div class="darkbg">
<h1 class="thread-title"><i><%= user.username %></i>'s profile</h1>
<% if user_is_me then -%>
<div class="user-actions">
<a class="linkbutton" href="<%= url_for("user_settings", {username = user.username}) %>">Settings</a>
<form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>">
<input type="submit" value="Log out">
<input class="warn" type="submit" value="Log out">
</form>
</div>
<% if user:is_guest() then %>
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
<% end %>
<% if me:is_admin() and user:is_guest() then %>
<p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p>
<form method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<% end %>
<% if me:is_mod() and not user:is_system() then %>
<h1 class="thread-title">Moderator controls</h1>
<% if user:is_guest() then %>
<p>This user is a guest. They signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%>.</p>
<form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<input type="submit" value="Confirm user">
</form>
<% elseif me:is_admin() then %>
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p>
<% else %> <% --[[ user is not guest ]] %>
<p>This user signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%> and was confirmed on <% render("views.common.timestamp", {timestamp = user.confirmed_on}) %>.</p>
<% if user.permission < me.permission then %>
<form class="modform" method="post" action="<%= url_for("guest_user", {user_id = user.id}) %>">
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
</form>
<% end %>
<% if me:is_admin() and not user:is_mod() then %>
<form class="modform" method="post" action="<%= url_for("mod_user", {user_id = user.id}) %>">
<input class="warn" type="submit" value="Promote user to moderator">
</form>
<% elseif user:is_mod() and user.permission < me.permission then %>
<form class="modform" method="post" action="<%= url_for("demod_user", {user_id = user.id}) %>">
<input class="critical" type="submit" value="Demote user to regular user">
</form>
<% end %>
<% end %>
<% end %>
</div>
<div class="user-info">
<div class="user-page-usercard">
<div class="usercard-inner">
<img class="avatar" src="<%= avatar_url(user) %>">
<strong class="big"><%= user.username %></strong>
<% if user.status ~= "" then %>
<em class="user-status"><%= user.status %></em>
<% end %>
<% if user.signature_rendered ~= "" then %>
Signature:
<div>
<%- user.signature_rendered %>
</div>
<% end %>
</div>
</div>
<div class="user-page-stats">
<ul class="user-stats-list">
<li>Permission: <%= PermissionLevelString[user.permission] %></li>
<li>Posts created: <%= stats.post_count %></li>
<li>Threads started: <%= stats.thread_count %></li>
<% if stats.latest_thread_title then %>
<li>Latest started thread: <a href="<%= url_for("thread", {slug = stats.latest_thread_slug}) %>"><%= stats.latest_thread_title %></a></li>
<% end %>
</ul>
Latest posts:
<div class="user-page-posts">
<% for _, post in ipairs(latest_posts) do %>
<div class="post-content-container">
<div class="post-info">
<% local post_url = get_post_url(post.id) %>
<a href="<%= post_url %>" title="Permalink"><i>
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%> in <%= post.thread_title %>
<% else -%>
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%> in <%= post.thread_title %>
<% end -%>
</i></a>
</div>
<div class="post-content wider user-page-post-preview">
<div class="post-inner"><%- post.content %></div>
</div>
</div>
<% end %>
</div>
</div>
</div>