Compare commits

...

58 Commits

Author SHA1 Message Date
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
69 changed files with 1853 additions and 203 deletions

8
.dockerignore Normal file
View File

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

11
.gitignore vendored
View File

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

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# HOW TO:
#
# docker compose up
#
# it exposes the data/ and secrets/ volumes in app root
#
FROM openresty/openresty: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)"
RUN luarocks --lua-version=5.1 build --only-deps
EXPOSE 8080
RUN chmod +x /app/start.sh
ENTRYPOINT ["/app/start.sh", "production"]

View File

@ -6,34 +6,45 @@ Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording.
# installing & first time setup
1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html).
2. then, install LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
3. then, install [LuaRocks](https://luarocks.org) (prefer your package manager instead of a local install recommended by the guide)
4. add luarocks search dirs to path:
## docker
```bash
$ docker compose up
```
- opens port 8080
- exposes `data/db` and `data/avatars` as volumes for data backup and persistence
- exposes `secrets/` as a volume so that the script won't try to perform first time setup again
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
## manual
1. install:
- OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html)
- LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
- openssl (-dev)
- sqlite (-dev)
- libsodium (-dev)
- imagemagick (-dev)
- [LuaRocks](https://luarocks.org) (either through the guide's instructions or your package manager, whichever is newer)
2. add luarocks search dirs to path:
```bash
# in .bashrc (or other shell equivalent)
eval "$(luarocks --lua-version 5.1 path)"
```
5. clone repo
6. install the dependencies:
3. clone repo
4. install the lua dependencies:
```bash
$ luarocks --local --lua-version 5.1 build --only-deps
```
7. create a file named `secrets.lua` in the project directory.
use the `secrets.lua.example` file as reference, and generate a cryptographically secure random key, for example, with:
5. run:
```bash
$ openssl rand -hex 32
```
8. run:
```bash
$ start.sh production
$ 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.)
(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]).
@ -43,3 +54,6 @@ once you are able to navigate to the forum, you can log in as the administrator
# 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/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 (the project has many more contributors, but these are the people whose designs were reproduced here)

28
app.lua
View File

@ -1,6 +1,10 @@
local lapis = require("lapis")
local date = require("date")
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
@ -11,8 +15,23 @@ 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)
@ -21,6 +40,13 @@ local function inject_methods(req)
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
util.pop_infobox(req)
end
@ -32,6 +58,8 @@ app:include("apps.users", {path = "/user"})
app:include("apps.topics", {path = "/topics"})
app:include("apps.threads", {path = "/threads"})
app:include("apps.mod", {path = "/mod"})
app:include("apps.post", {path = "/post"})
app:include("apps.api", {path = "/api"})
app:get("/", function(self)
return {redirect_to = self:url_for("all_topics")}

36
apps/api.lua Normal file
View File

@ -0,0 +1,36 @@
local app = require("lapis").Application()
local sse = require("lib.sse")
local db = require("lapis.db")
local util = require("util")
app:get("sse_thread_updates", "/thread-updates/:thread_id", function(self)
do
local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id)
if #thread == 0 then
return {status = 404, skip_render = true}
end
end
local now = os.time()
local stream = sse:new()
local thread_id = self.params.thread_id
local new_posts_query = "SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1"
while stream.active do
stream:dispatch()
local new_post = db.query(new_posts_query, thread_id, now)
if #new_post > 0 then
local url = util.get_post_url(self, new_post[1].id)
stream:enqueue(url, "new_post_url")
end
ngx.sleep(5)
end
return {skip_render = true}
end)
return app

View File

@ -1,23 +1,46 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local util = require("util")
local models = require("models")
local Users = models.Users
app:get("user_list", "/list", function(self)
-- 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
return {redirect_to = self:url_for("all_topics")}
self:write{redirect_to = self:url_for("all_topics")}
return
end
if not self.me:is_mod() then
return {redirect_to = self:url_for("all_topics")}
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")
@ -22,7 +23,7 @@ app:get("thread_create", "/create", function(self)
return "how did you get here?"
end
self.all_topics = all_topics
self.page_title = "creating thread"
self.page_title = "drafting a thread"
self.me = user
return {render = "threads.create"}
end)
@ -35,7 +36,10 @@ app:post("thread_create", "/create", function(self)
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)
@ -54,7 +58,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})}
@ -86,24 +90,11 @@ app:get("thread", "/:slug", function(self)
end
-- self.page = math.max(1, math.min(self.page, self.pages))
local posts = db.query([[
SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, 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 = ?
ORDER BY
posts.created_at ASC
LIMIT ? OFFSET ?
]], thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
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
@ -145,4 +136,74 @@ app:post("thread", "/:slug", function(self)
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)
return app

View File

@ -53,7 +53,7 @@ app:get("topic_create", "/create", function(self)
return {status = 403}
end
self.page_title = "creating topic"
self.page_title = "creating a topic"
self.me = user
return {render = "topics.create"}
@ -194,4 +194,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,11 +1,13 @@
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")
@ -14,7 +16,7 @@ local Sessions = models.Sessions
local Avatars = models.Avatars
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()
@ -114,13 +116,20 @@ app:post("user_delete", "/:username/delete", function(self)
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)
util.inject_infobox(self, "Your account has been added to the deletion queue.")
return {redirect_to = self:url_for("user_signup")}
end)
@ -177,7 +186,7 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
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
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
@ -197,6 +206,35 @@ 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
@ -225,11 +263,15 @@ app:post("user_settings", "/:username/settings", function(self)
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)
target_user:update({
status = status,
signature_original_markup = original_sig,
signature_rendered = rendered_sig,
})
util.inject_infobox(self, "Status updated.")
util.inject_infobox(self, "Settings updated.")
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
@ -321,7 +363,7 @@ app:post("user_signup", "/signup", function(self)
local new_user = Users:create({
username = username,
password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS),
password_hash = auth.digest(password),
permission = constants.PermissionLevel.GUEST,
})
@ -339,6 +381,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)

View File

@ -1,16 +1,23 @@
local config = require("lapis.config")
local secrets = require("secrets")
local secrets = require("secrets.secrets")
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", {
@ -20,7 +27,7 @@ config("production", {
},
secret = secrets.key,
sqlite = {
database = "db.prod.sqlite"
database = "data/db/db.prod.sqlite"
},
session_name = "porom_session_s"
})

View File

@ -8,6 +8,19 @@ Constants.PermissionLevel = {
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",

View File

@ -1,4 +1,4 @@
local bcrypt = require("bcrypt")
local auth = require("lib.auth")
local models = require("models")
local constants = require("constants")
@ -23,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,

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

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: 527 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 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/wink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

View File

@ -1,8 +1,36 @@
/* src: */
@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;
}
.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.9rem;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
@ -11,7 +39,7 @@
}
body {
font-family: sans-serif;
font-family: "Cadman";
margin: 20px 100px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
@ -26,7 +54,7 @@ body {
justify-content: end;
background-color: #c1ceb1;
justify-content: space-between;
align-items: center;
align-items: baseline;
}
#bottomnav {
@ -49,9 +77,9 @@ body {
}
.site-title {
padding-right: 30px;
font-size: 1.5rem;
font-weight: bold;
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
}
@ -86,7 +114,7 @@ body {
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 2.5fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content";
@ -105,7 +133,83 @@ body {
.post-content {
grid-area: post-content;
padding: 5px 20px;
padding: 20px;
margin-right: 25%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.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;
}
.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 {
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;
}
.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-posts {
@ -274,7 +378,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
.infobox {
border: 2px solid black;
background-color: #c1ceb1;
background-color: #81a3e6;
padding: 20px 15px;
}
.infobox.critical {
@ -326,6 +430,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
width: 50%;
}
.block-img {
object-fit: contain;
max-width: 400px;
max-height: 400px;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: #c1ceb1;
@ -334,12 +444,16 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
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%;
}
.topic {
@ -364,3 +478,57 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
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;
}
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);
}

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.

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

@ -0,0 +1,54 @@
{
let ta = document.getElementById("babycode-content");
const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike");
const buttonCode = document.getElementById("post-editor-code");
function insertTag(tagStart, newline = false) {
const tagEnd = tagStart;
const tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
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;
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
ta.focus()
} else {
const cursor = ta.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substr(cursor);
const newCursor = strStart.length + tagInsertStart.length;
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")
})
buttonCode.addEventListener("click", (e) => {
e.preventDefault();
insertTag("code", true)
})
}

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")
}

83
js/thread.js Normal file
View File

@ -0,0 +1,83 @@
{
const ta = document.getElementById("babycode-content");
for (let button of document.querySelectorAll(".reply-button")) {
button.addEventListener("click", (e) => {
ta.value += button.value;
ta.scrollIntoView()
})
}
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`
})
}
let newPostSubscription = null;
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 = () => {
hideNotification();
reconnectSSE();
}
document.getElementById("go-to-new-post-button").href = url;
document.getElementById("unsub-new-post-button").onclick = () => {
hideNotification()
}
}
function reconnectSSE() {
if (newPostSubscription) newPostSubscription.close();
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
newPostSubscription = new EventSource(threadEndpoint);
newPostSubscription.onerror = (e) => {
console.error(e);
};
newPostSubscription.addEventListener("new_post_url", (e) => {
showNewPostNotification(e.data);
newPostSubscription.close();
})
}
window.addEventListener('beforeunload', () => {
if(newPostSubscription)
{
newPostSubscription.close();
}
});
reconnectSSE();
}

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();
}
})
}

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

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

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

View File

@ -1,50 +1,167 @@
local babycode = {}
local string_trim = require("lapis.util").trim
local emoji = require("lib.babycode-emoji")
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 get_list_items(list_body, escape_html)
list_body = list_body:gsub(" +%s*\r?\n", "<br>")
list_body = list_body:gsub("(%S)(\r?\n\r?\n)\r?\n*", "%1\1")
local list_items = s_split(list_body, "\1")
local lis = ""
for _, li in ipairs(list_items) do
local rendered = babycode.to_html(li, escape_html)
lis = lis .. "<li>" .. rendered .. "</li>"
end
return lis
end
---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
local text = escape_html(s)
-- extract code blocks 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
-- strip leading and trailing newlines, preserve others
code_blocks[code_count] = code:gsub("^%s*(.-)%s*$", "%1")
return "\1CODE:"..code_count.."\1"
end)
-- 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>'
local inline_codes = {}
text = text:gsub("%[code%](.-)%[/code%]", function(code)
local is_inline = code:match("\n") == nil
if is_inline then
table.insert(inline_codes, code)
return "\1ICODE:"..#inline_codes.."\1"
else
-- strip leading and trailing newlines, preserve others
local m, _ = code:gsub("^%s*(.-)%s*$", "%1")
table.insert(code_blocks, m)
return "\1CODE:"..#code_blocks.."\1"
end
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>'
text = text:gsub("%[ul%](.-)%[/ul%]", function(list_body)
return "<ul>" .. get_list_items(list_body, escape_html) .. "</ul>"
end)
text = text:gsub("%[ol%](.-)%[/ol%]", function(list_body)
return "<ol>" .. get_list_items(list_body, escape_html) .. "</ol>"
end)
-- images
local images = {}
text = text:gsub("%[img=(.-)%](.-)%[/img%]", function (img, alt)
table.insert(images, {img = img, alt = alt})
return "\1IMG:"..#images.."\1"
end)
-- normalize newlines, attempt #4
text = text:gsub(" +%s*\r?\n", "<br>")
text = text:gsub("(%S)(\r?\n\r?\n)\r?\n*", "%1<br><br>")
local url_tags = {}
-- replace `[url=https://example.com]Example[/url] tags
text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
table.insert(url_tags, {url = url, label = label})
return "\1URL:"..#url_tags.."\1"
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>")
-- these can be nested, so replace open and closed separately
text = text:gsub("%[(/?)quote%]", "<%1blockquote>")
text = text:gsub(":(.-):", function(code)
if emoji[code] then
return emoji[code]
else
return code
end
end)
-- 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>'
return '<a href="'..url..'">'..url..'</a>'
end
return url
end)
-- normalize newlines, replace them with <br>
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
text = text:gsub("\1URL:(%d+)\1", function(n)
local url = url_tags[tonumber(n)]
return ("<a href=%s>%s</a>"):format(url.url, url.label)
end)
-- rule
text = text:gsub("\n+%-%-%-", "<hr>")
-- <div class=\"post-img-container\"><img src=%1 alt=%2></div>
text = text:gsub("\1IMG:(%d+)\1", function (n)
local img = images[tonumber(n)]
return ("<div class=\"block-img-container\"><img class=\"block-img\" src=\"%s\" alt=\"%s\"></div>"):format(img.img, img.alt)
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>"
local code = code_blocks[tonumber(n)]
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(code)
return "<pre><span class=\"copy-code-container\">" .. button .. "</span><code>"..code.."</code></pre>"
end)
text = text:gsub("\1ICODE:(%d+)\1", function (n)
local code = inline_codes[tonumber(n)]
return "<code class=\"inline-code\">" .. code .. "</code>"
end)
return text

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

@ -62,5 +62,33 @@ return {
[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
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,
}

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/;
}
}
}

View File

@ -16,7 +16,7 @@ dependencies = {
"lapis == 1.16.0",
"lsqlite3",
"magick",
"bcrypt",
"luasodium",
"luaossl",
}

View File

@ -1,11 +1,44 @@
/* src: */
@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%);
@ -16,7 +49,8 @@ $button_color: color.adjust($accent_color, $hue: 90);
%button-base {
cursor: default;
color: black;
font-size: 0.9rem;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
@ -49,7 +83,8 @@ $button_color: color.adjust($accent_color, $hue: 90);
}
body {
font-family: sans-serif;
font-family: "Cadman";
// font-size: 18px;
margin: 20px 100px;
background-color: $main_bg;
}
@ -61,7 +96,7 @@ body {
#topnav {
@include navbar($accent_color);
justify-content: space-between;
align-items: center;
align-items: baseline;
}
#bottomnav {
@ -81,9 +116,9 @@ body {
}
.site-title {
padding-right: 30px;
font-size: 1.5rem;
font-weight: bold;
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
}
@ -119,7 +154,7 @@ body {
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 2.5fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
@ -140,7 +175,85 @@ body {
.post-content {
grid-area: post-content;
padding: 5px 20px;
padding: 20px;
margin-right: 25%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.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;
}
.inline-code {
background-color: $verydark;
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
}
#delete-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;
}
.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-posts {
@ -272,7 +385,7 @@ input[type="text"], input[type="password"], textarea, select {
.infobox {
border: 2px solid black;
background-color: $accent_color;
background-color: #81a3e6;
padding: 20px 15px;
&.critical {
@ -327,6 +440,12 @@ input[type="text"], input[type="password"], textarea, select {
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;
@ -335,12 +454,16 @@ input[type="text"], input[type="password"], textarea, select {
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%;
}
.topic {
@ -366,3 +489,60 @@ input[type="text"], input[type="password"], textarea, select {
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;
}
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);
}

View File

@ -1,15 +1,24 @@
#!/bin/bash
set -e
start() {
lapis migrate
lapis serve
}
first_launch() {
echo "Setting up for the first time"
touch ".first_launch.$LAPIS_ENVIRONMENT"
lua5.1 schema.lua
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
lua5.1 create_default_accounts.lua
luajit create_default_accounts.lua
}
if [[ $# -ne 1 ]]; then
@ -21,7 +30,7 @@ fi
echo "Starting in $LAPIS_ENVIRONMENT"
if ! [ -f ".first_launch.$LAPIS_ENVIRONMENT" ]; then
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
first_launch
fi

209
util.lua
View File

@ -3,11 +3,13 @@ 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")
@ -33,10 +35,140 @@ util.TransientUser = {
username = "Deleted User",
}
-- PURE API
function util.get_user_avatar_url(req, user)
return Avatars:find(user.avatar_id).file_path
end
---split a string
---@param s string subject
---@param delimiter string? string to split by, can be empty to split by character
---@param max_matches integer? the maximum number of returned elements
---@param trim boolean? whether to trim whitespace off matches
---@param allow_empty boolean? should empty matches be in the resulting table
---@return string[]
function util.s_split(s, delimiter, max_matches, trim, allow_empty)
local result = {}
if s == "" then
return result
end
trim = trim == nil and true or trim
local tr = function(subj)
if trim then return string_trim(subj) else return subj end
end
max_matches = max_matches or -1
allow_empty = allow_empty == nil and true or allow_empty
if delimiter == "" then
for i=1, #s do
local c = s:sub(i, 1)
if allow_empty or c ~= "" then
table.insert(result, c)
if max_matches > 0 and #result == max_matches then
break
end
end
end
return result
end
local current_pos = 1
local delim_len = #delimiter
while true do
if max_matches > 0 and #result >= max_matches then
break
end
---@diagnostic disable-next-line: param-type-mismatch
local start_pos, end_pos = s:find(delimiter, current_pos, true)
if not start_pos then
break
end
local substr = s:sub(current_pos, start_pos - 1)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
current_pos = end_pos + 1
end
local substr = s:sub(current_pos)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
return result
end
function util.split_sentences(sentences, max_sentences)
return util.s_split(sentences, ".", max_sentences or 2, true, false)
end
---@return string
function util.get_post_url(req, post_id)
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
return req:url_for("thread", {slug = thread.slug}, {after = post_id}) .. "#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)
@ -81,7 +213,7 @@ function util.destroy_avatar(avatar_id)
return
end
local file_path = "static" .. avatar.file_path
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")
@ -92,49 +224,8 @@ function util.destroy_avatar(avatar_id)
end
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)
if s == "true" then
return true
end
if s == "false" then
return false
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,
@ -142,12 +233,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,
content = bb_content,
content = parsed_content,
is_initial_revision = true,
original_markup = content,
markup_language = "babycode",
})
post:update({current_revision_id = revision.id})
@ -156,6 +252,27 @@ 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",

View File

@ -7,11 +7,16 @@
<% else %>
<title>Porom</title>
<% end %>
<% math.randomseed(os.time()) %>
<link rel="stylesheet" href="<%= "/static/style.css?" .. math.random(1, 100) %>">
<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>
</body>
</html>

View File

@ -0,0 +1,8 @@
<span>
<button type=button id="post-editor-bold" title="Insert Bold">B</button>
<button type=button id="post-editor-italics" title="Insert Italics">I</button>
<button type=button id="post-editor-strike" title="Insert Strikethrough">S</button>
<button type=button id="post-editor-code" title="Insert Code block">Code</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>
<script src="/static/js/babycode-editor.js"></script>

View File

@ -0,0 +1,16 @@
<%
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}) %>
<span>
<input type=submit value="<%= save_button_text %>">
<% if cancel_url then %>
<a class="linkbutton warn" href="<%= cancel_url %>">Cancel</a>
<% end %>
</span>
<% render("views.common.bbcode_help") %>
</form>

View File

@ -1,11 +1,62 @@
<details>
<summary>Supported babycode tags</summary>
<summary>babycode guide</summary>
<ul>
<li>
<details>
<summary>Forumoji (emoticons)</summary>
<ul>
<li><img class="emoji" src="/emoji/smile.png" alt="smile" title="smile"> - <code class=inline-code>:): </code></li>
<li><img class="emoji" src="/emoji/frown.png" alt="frown" title="frown"> - <code class=inline-code>:(: </code></li>
<li><img class="emoji" src="/emoji/grin.png" alt="grin" title="grin"> - <code class=inline-code>:D: </code></li>
<li><img class="emoji" src="/emoji/neutral.png" alt="neutral" title="neutral"> - <code class=inline-code>:|: </code></li>
<li><img class="emoji" src="/emoji/angry.png" alt="angry" title="angry"> - <code class=inline-code>:angry: </code></li>
<li><img class="emoji" src="/emoji/sob.png" alt="sob" title="sob"> - <code class=inline-code>:,: :cry: :sob: :T: </code></li>
<li><img class="emoji" src="/emoji/surprised.png" alt="surprised" title="surprised"> - <code class=inline-code>:o: :O: </code></li>
<li><img class="emoji" src="/emoji/think.png" alt="think" title="think"> - <code class=inline-code>:hmm: :think: :thinking: </code></li>
<li><img class="emoji" src="/emoji/tongue.png" alt="tongue" title="tongue"> - <code class=inline-code>:p: :P: </code></li>
<li><img class="emoji" src="/emoji/wink.png" alt="wink" title="wink"> - <code class=inline-code>:;: :wink: </code></li>
<li><img class="emoji" src="/emoji/imp.png" alt="imp" title="imp"> - <code class=inline-code>:imp: </code></li>
<li><img class="emoji" src="/emoji/impangry.png" alt="impangry" title="impangry"> - <code class=inline-code>:angryimp: :impangry: </code></li>
</ul>
</details>
</li>
<li>Loose links will be converted to clickable links automatically</li>
<li>[b]<b>bold</b>[/b]</li>
<li>[i]<i>italic</i>[/i]</li>
<li>[s]<del>strikethrough</del>[/s]</li>
<li>[img=https://example.com/some-image]alt text[/img] creates an image</li>
<li>[url=https://example.com]<a href="https://example.com">labeled URL</a>[/url]</li>
<li>[url]<a href="https://unlabeled-url.example.com">https://unlabeled-url.example.com</a>[/url]</li>
<li>[code]<code>code block</code>[/code]</li>
<li>
[ul] and [ol] are unordered and ordered lists:
<details>
<summary>Show list example</summary>
<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>
</details>
</li>
<li>
[code]with<br>line breaks[/code] will produce a code block:
<details>
<summary>Show code block example</summary>
<pre><span class="copy-code-container"><button type=button class="copy-code" value="with
line breaks">Copy</button></span><code>with
line breaks</code></pre>
</details>
</li>
<li>[code]<code class="inline-code">with no line breaks</code>[/code]</li>
<li><code class="inline-code">---</code> will create a horizontal rule for separating content</li>
</ul>
</details>
</details>

View File

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

View File

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

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

@ -9,8 +9,8 @@
</select><br>
<label for="title">Thread title</label>
<input type="text" id="title" name="title" placeholder="Required" required>
<label for="initial_post">Post body</label>
<textarea id="initial_post" name="initial_post" placeholder="Required" rows=5 required></textarea>
<label for="initial_post">Post body</label><br>
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %>
<% render "views.common.bbcode_help" %>
<input type="submit" value="Create thread">
</form>

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>

View File

@ -1,4 +1,10 @@
<div class="post" id="post-<%= post.id %>">
<%
local pc = "post"
if edit then
pc = pc .. " editing"
end
%>
<div class="<%= pc %>" id="post-<%= post.id %>">
<div class="usercard">
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
<img src="<%= post.avatar_path %>" class="avatar">
@ -8,19 +14,69 @@
<em class="user-status"><%= post.status %></em>
<% end %>
</div>
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
<div class="post-info">
<div><a href="<%= "#post-" .. post.id %>" title="Permalink"><i>
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
Edited at <%= os.date("%c", post.edited_at) %>
<% else -%>
Posted at <%= os.date("%c", post.created_at) %>
<% end -%>
</i></a></div>
<div><button>Reply</button></div>
<%
local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. 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}) %>">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---\n\n"):format(quote_src_text, post.original_markup)
%>
<button value="<%= reply_text %>" class="reply-button">Reply</button>
<% end %>
<%
local show_delete = (post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()
if show_delete then
%>
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
<% end %>
</span>
</div>
<div class="post-content">
<%- 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,11 +1,45 @@
<% local is_locked = ntob(thread.is_locked) %>
<%
local is_locked = ntob(thread.is_locked)
local is_stickied = ntob(thread.is_stickied)
local can_post = (not is_locked and not me:is_guest()) or me:is_mod()
local can_lock = me.id == thread.user_id or me:is_mod()
%>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<main>
<nav class="darkbg">
<h1 class="thread-title"><%= thread.title %></h1>
<span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a></span>
<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>
<% if can_lock then %>
<div>
<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 %>
</div>
<% end %>
</nav>
<% for i, post in ipairs(posts) do %>
<% render("views.threads.post", {post = post, is_latest = i == #posts}) %>
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
<% end %>
</main>
@ -16,10 +50,20 @@
<% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
<% end -%>
<% if not me:is_guest() and not is_locked 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="Post reply">
</form>
<% 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("sse_thread_updates", {thread_id = thread.id}) %>">
<% render("views.threads.new-post-notification") %>
<script src="/static/js/thread.js"></script>

View File

@ -2,6 +2,8 @@
<% 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>
@ -12,25 +14,28 @@
<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>
<% else %>
<p>This topic is locked.</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 ntob(topic.is_locked) %>">
<input class="warn" type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock topic" or "Lock topic" %>">
<input type="hidden" name="is_locked" value="<%= not is_locked %>">
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>">
</form>
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
<% end %>
</div>
</nav>
<% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This topic is locked. Only moderators can create new threads."}) %>
<% end -%>
<% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p>
<% else %>
<% for _, thread in ipairs(threads_list) do %>
<% local is_stickied = ntob(thread.is_stickied) %>
<% local is_locked = ntob(thread.is_locked) %>
<% local thread_is_locked = ntob(thread.is_locked) %>
<div class="thread">
<div class="thread-sticky-container contain-svg">
<% if is_stickied then -%>
@ -43,18 +48,18 @@
<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 <%= os.date("%c", thread.created_at) %>
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 <%= os.date("%c", thread.latest_post_created_at) %></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 is_locked then -%>
<% if thread_is_locked then -%>
<% render("svg-icons.lock") %>
<i>Locked</i>
<% end -%>
@ -66,3 +71,15 @@
<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

@ -2,9 +2,14 @@
<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 %>
@ -12,11 +17,11 @@
<% local is_locked = ntob(topic.is_locked) %>
<div class="topic">
<div class="topic-info-container">
<a href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
<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 <%= os.date("%c", topic.latest_thread_created_at) %>
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>
<% else %>
<i>No threads yet.</i>

View File

@ -8,7 +8,7 @@
<img src="<%= avatar_url(me) %>">
<input id="file" type="file" name="avatar" accept="image/*" required>
<div>
<input type="submit" value="Update avatar" <%= disable_avatar and "disabled=disabled" %>>
<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 %>
@ -16,10 +16,21 @@
</form>
<form method="post" action="">
<label for="status">Status</label>
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="30">
<input type="submit" value="Save status">
<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 type="submit" value="Save settings">
</form>
<div>
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
</div>
<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,5 +1,5 @@
<% if infobox then %>
<% render("views.common.infobox", pop_infobox) %>
<% render("views.common.infobox", infobox) %>
<% end %>
<div class="darkbg">
<h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>
@ -27,9 +27,9 @@
<div class="post-info">
<div><a href="<%= url_for("thread", {slug = post.thread_slug}, {after = post.id}) .. "#post-" .. post.id %>" title="Permalink"><i>
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
Edited in <%= post.thread_title %> at <%= os.date("%c", post.edited_at) %>
Edited in <%= post.thread_title %> at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
<% else -%>
Posted in <%= post.thread_title %> at <%= os.date("%c", post.created_at) %>
Posted in <%= post.thread_title %> on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
<% end -%>
</i></a></div>
</div>
@ -48,12 +48,12 @@
<div class="darkbg">
<h1>Moderator controls</h2>
<% if user:is_guest() then %>
<p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p>
<p>This user is a guest. They signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%>.</p>
<form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<input type="submit" value="Confirm user">
</form>
<% else %> <% --[[ user is not guest ]] %>
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p>
<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)">