Compare commits

...

35 Commits

Author SHA1 Message Date
ca23415288 feat: allow containerized deployments
At the moment, it seems like it should be working, but I get:
```
lua5.1: error loading module 'bcrypt' from file '/usr/local/openresty/luajit/lib/lua/5.1/bcrypt.so':
	Error relocating /usr/local/openresty/luajit/lib/lua/5.1/bcrypt.so: luaL_setfuncs: symbol not found
```
2025-05-22 11:25:21 +02: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
45 changed files with 1809 additions and 248 deletions

6
.gitignore vendored
View File

@ -3,4 +3,8 @@ nginx.conf.compiled
db.*.sqlite db.*.sqlite
.vscode/ .vscode/
.local/ .local/
static/ static/avatars/*
!static/avatars/default.webp
secrets.lua
.first_launch.*

View File

@ -5,13 +5,41 @@ porous forum
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html). Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording. Please read the [full terms](./LICENSE.md) for proper wording.
# deps # installing & first time setup
this is all off the top of my head so if you try to run it got help you 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:
- lapis ```bash
- lsqlite3 # in .bashrc (or other shell equivalent)
- [magick](https://github.com/leafo/magick) eval "$(luarocks --lua-version 5.1 path)"
- bcrypt ```
- luaossl 5. clone repo
6. install the dependencies:
i think thats it ```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:
```bash
$ openssl rand -hex 32
```
8. run:
```bash
$ start.sh production
```
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.)
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)

18
app.lua
View File

@ -1,26 +1,40 @@
local lapis = require("lapis") local lapis = require("lapis")
local app = lapis.Application() local app = lapis.Application()
local constants = require("constants")
local db = require("lapis.db")
-- sqlite starts without foreign key enforcement
db.query("PRAGMA foreign_keys = ON")
local util = require("util") local util = require("util")
app:enable("etlua") app:enable("etlua")
app.layout = require "views.base" app.layout = require "views.base"
local function inject_constants(req)
req.constants = constants
end
local function inject_methods(req) local function inject_methods(req)
req.avatar_url = util.get_user_avatar_url req.avatar_url = util.get_user_avatar_url
req.ntob = function(_, v) req.ntob = function(_, v)
return util.ntob(v) return util.ntob(v)
end end
req.PermissionLevelString = constants.PermissionLevelString
util.pop_infobox(req)
end end
app:before_filter(inject_constants)
app:before_filter(inject_methods) app:before_filter(inject_methods)
app:include("apps.users", {path = "/user"}) app:include("apps.users", {path = "/user"})
app:include("apps.topics", {path = "/topics"}) app:include("apps.topics", {path = "/topics"})
app:include("apps.threads", {path = "/threads"}) app:include("apps.threads", {path = "/threads"})
app:include("apps.mod", {path = "/mod"})
app:get("/", function() app:get("/", function(self)
return "Welcome to Lapis " .. require("lapis.version") return {redirect_to = self:url_for("all_topics")}
end) end)
return app return app

23
apps/mod.lua Normal file
View File

@ -0,0 +1,23 @@
local app = require("lapis").Application()
local util = require("util")
local models = require("models")
local Users = models.Users
app:get("user_list", "/list", function(self)
self.me = util.get_logged_in_user(self)
if not self.me then
return {redirect_to = self:url_for("all_topics")}
end
if not self.me:is_mod() then
return {redirect_to = self:url_for("all_topics")}
end
self.users = Users:select("")
return {render = "mod.user-list"}
end)
return app

View File

@ -9,10 +9,12 @@ local Topics = models.Topics
local Threads = models.Threads local Threads = models.Threads
local Posts = models.Posts local Posts = models.Posts
local POSTS_PER_PAGE = 10
app:get("thread_create", "/create", function(self) app:get("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self) local user = util.get_logged_in_user(self)
if not user then 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")} return {redirect_to = self:url_for("user_login")}
end end
local all_topics = db.query("select * from topics limit 25;") local all_topics = db.query("select * from topics limit 25;")
@ -20,13 +22,15 @@ app:get("thread_create", "/create", function(self)
return "how did you get here?" return "how did you get here?"
end end
self.all_topics = all_topics self.all_topics = all_topics
self.page_title = "creating thread"
self.me = user
return {render = "threads.create"} return {render = "threads.create"}
end) end)
app:post("thread_create", "/create", function(self) app:post("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self) local user = util.get_logged_in_user(self)
if not user then 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")} return {redirect_to = self:url_for("user_login")}
end end
local topic = Topics:find(self.params.topic_id) local topic = Topics:find(self.params.topic_id)
@ -64,9 +68,27 @@ app:get("thread", "/:slug", function(self)
return {status = 404} return {status = 404}
end end
self.thread = thread self.thread = thread
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 posts = db.query([[ local posts = db.query([[
SELECT SELECT
posts.id, post_history.content, users.username, avatars.file_path AS avatar_path posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path
FROM FROM
posts posts
JOIN JOIN
@ -76,14 +98,17 @@ app:get("thread", "/:slug", function(self)
LEFT JOIN LEFT JOIN
avatars ON users.avatar_id = avatars.id avatars ON users.avatar_id = avatars.id
WHERE WHERE
posts.thread_id = ? and posts.id > ? posts.thread_id = ?
ORDER BY ORDER BY
posts.created_at ASC posts.created_at ASC
LIMIT 20 LIMIT ? OFFSET ?
]], thread.id, tonumber(self.params.cursor or 0)) ]], thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
self.user = util.get_logged_in_user_or_transient(self) self.topic = Topics:find(thread.topic_id)
self.me = util.get_logged_in_user_or_transient(self)
self.posts = posts self.posts = posts
self.next_cursor = #posts > 0 and posts[#posts].id or nil
self.page_title = thread.title
return {render = "threads.thread"} return {render = "threads.thread"}
end) end)
@ -103,17 +128,21 @@ app:post("thread", "/:slug", function(self)
return {redirect_to = self:url_for("thread", {slug = thread.slug})} return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end 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})} return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end end
local post_content = self.params.post_content local post_content = self.params.post_content
local post = util.create_post(thread.id, user.id, 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 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 end
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) end)
return app return app

View File

@ -12,6 +12,8 @@ local Avatars = models.Avatars
local Topics = models.Topics local Topics = models.Topics
local Threads = models.Threads local Threads = models.Threads
local THREADS_PER_PAGE = 10
local ThreadCreateError = { local ThreadCreateError = {
OK = 0, OK = 0,
GUEST = 1, GUEST = 1,
@ -20,23 +22,46 @@ local ThreadCreateError = {
} }
app:get("all_topics", "", function(self) app:get("all_topics", "", function(self)
self.topic_list = db.query("select * from topics limit 25;") self.topic_list = db.query([[
self.user = util.get_logged_in_user(self) or util.TransientUser SELECT
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
]])
self.me = util.get_logged_in_user_or_transient(self)
return {render = "topics.topics"} return {render = "topics.topics"}
end) end)
app:get("topic_create", "/create", function(self) app:get("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser 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} return {status = 403}
end end
self.page_title = "creating topic"
self.me = user
return {render = "topics.create"} return {render = "topics.create"}
end) end)
app:post("topic_create", "/create", function(self) app:post("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser 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"} return {redirect_to = "all_topics"}
end end
@ -45,13 +70,17 @@ app:post("topic_create", "/create", function(self)
local time = os.time() local time = os.time()
local slug = lapis_util.slugify(topic_name) .. "-" .. time local slug = lapis_util.slugify(topic_name) .. "-" .. time
local topic_count = Topics:count()
local topic = Topics:create({ local topic = Topics:create({
name = topic_name, name = topic_name,
description = topic_description, description = topic_description,
slug = slug, slug = slug,
sort_order = topic_count + 1,
}) })
util.inject_infobox(self, "Topic created.")
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("topic", {slug = topic.slug})}
end) end)
app:get("topic", "/:slug", function(self) app:get("topic", "/:slug", function(self)
@ -61,29 +90,69 @@ app:get("topic", "/:slug", function(self)
if not topic then if not topic then
return {status = 404} return {status = 404}
end end
self.topic = topic local threads_count = Threads:count(db.clause({
self.threads_list = Threads:select(db.clause({
topic_id = topic.id topic_id = topic.id
})) }))
self.topic = topic
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("SELECT * FROM threads WHERE topic_id = ? ORDER BY is_stickied DESC, created_at DESC", topic.id)
self.threads_list = db.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 BY
threads.is_stickied DESC,
threads.created_at DESC
LIMIT ? OFFSET ?
]], topic.id, THREADS_PER_PAGE, (self.page - 1) * THREADS_PER_PAGE)
local user = util.get_logged_in_user_or_transient(self) local user = util.get_logged_in_user_or_transient(self)
print(topic.is_locked, type(topic.is_locked)) self.me = user
self.user = user
self.ThreadCreateError = ThreadCreateError self.ThreadCreateError = ThreadCreateError
self.thread_create_error = ThreadCreateError.OK self.thread_create_error = ThreadCreateError.OK
if user:is_logged_in_guest() then if user:is_logged_in_guest() then
self.thread_create_error = ThreadCreateError.GUEST self.thread_create_error = ThreadCreateError.GUEST
elseif user:is_guest() then elseif user:is_guest() then
self.thread_create_error = ThreadCreateError.LOGGED_OUT 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 self.thread_create_error = ThreadCreateError.TOPIC_LOCKED
end end
self.page_title = "browsing topic " .. topic.name
return {render = "topics.topic"} return {render = "topics.topic"}
end) end)
app:get("topic_edit", "/:slug/edit", function(self) app:get("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(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})} return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end end
local topic = Topics:find({ local topic = Topics:find({
@ -93,12 +162,15 @@ app:get("topic_edit", "/:slug/edit", function(self)
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("all_topics")}
end end
self.topic = topic self.topic = topic
self.me = user
self.page_title = "editing topic " .. topic.name
return {render = "topics.edit"} return {render = "topics.edit"}
end) end)
app:post("topic_edit", "/:slug/edit", function(self) app:post("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(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})} return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end end
local topic = Topics:find({ local topic = Topics:find({

View File

@ -66,47 +66,103 @@ app:get("user", "/:username", function(self)
return {status = 404} return {status = 404}
end end
if self.session.flash ~= nil and self.session.flash.just_logged_in then local me = util.get_logged_in_user_or_transient(self)
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
self.user = user self.user = user
self.me = me self.me = me
self.user_is_me = me.id == user.id self.user_is_me = me.id == user.id
if user.permission == constants.PermissionLevel.GUEST then 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} return {status = 404}
end end
end end
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"} return {render = "user.user"}
end) 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) local me = util.get_logged_in_user(self)
if me == nil then 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 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
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)
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")} return {redirect_to = self:url_for("user_login")}
end end
local target_user = Users:find({username = self.params.username}) local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})} return {redirect_to = self:url_for("user", {username = self.params.username})}
end 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({ 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})} return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end) end)
app:post("user_set_avatar", "/:username/set_avatar", function(self) app:post("user_set_avatar", "/:username/set_avatar", function(self)
local me = util.get_logged_in_user(self) local me = util.get_logged_in_user(self)
if me == nil then 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")} return {redirect_to = self:url_for("user_login")}
end end
local target_user = Users:find({username = self.params.username}) local target_user = Users:find({username = self.params.username})
@ -115,7 +171,7 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
end end
local file = self.params.avatar local file = self.params.avatar
if not file then 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})} return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end end
local time = os.time() local time = os.time()
@ -124,11 +180,11 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
local save_path = "static" .. proxied_filename local save_path = "static" .. proxied_filename
local res = util.validate_and_create_image(file.content, save_path) local res = util.validate_and_create_image(file.content, save_path)
if not res then 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})} return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end end
self.session.flash = {success = true, msg = "Avatar updated."} util.inject_infobox(self, "Avatar updated.")
local avatar = Avatars:create({ local avatar = Avatars:create({
file_path = proxied_filename, file_path = proxied_filename,
uploaded_at = time, uploaded_at = time,
@ -144,30 +200,23 @@ end)
app:get("user_settings", "/:username/settings", function(self) app:get("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self) local me = util.get_logged_in_user(self)
if me == nil then 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")} return {redirect_to = self:url_for("user_login")}
end end
local target_user = Users:find({username = self.params.username}) local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})} return {redirect_to = self:url_for("user", {username = self.params.username})}
end end
if self.session.flash then self.me = target_user
local flash = self.session.flash self.page_title = "settings"
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
return {render = "user.settings"} return {render = "user.settings"}
end) end)
app:post("user_settings", "/:username/settings", function(self) app:post("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self) local me = util.get_logged_in_user(self)
if me == nil then 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")} return {redirect_to = self:url_for("user_login")}
end end
local target_user = Users:find({username = self.params.username}) local target_user = Users:find({username = self.params.username})
@ -180,10 +229,7 @@ app:post("user_settings", "/:username/settings", function(self)
target_user:update({ target_user:update({
status = status, status = status,
}) })
self.session.flash = { util.inject_infobox(self, "Status updated.")
success = true,
msg = "Settings updated."
}
return {redirect_to = self:url_for("user_settings", {username = self.params.username})} return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end) end)
@ -195,10 +241,8 @@ app:get("user_login", "/login", function(self)
end end
end end
if self.session.flash then self.page_title = "log in"
self.err = self.session.flash.error
self.session.flash = {}
end
return {render = "user.login"} return {render = "user.login"}
end) end)
@ -213,15 +257,19 @@ app:post("user_login", "/login", function(self)
local password = self.params.password local password = self.params.password
local user = Users:find({username = username}) local user = Users:find({username = username})
if not user then 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")} return {redirect_to = self:url_for("user_login")}
end end
if not authenticate_user(user, password) then 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")} return {redirect_to = self:url_for("user_login")}
end end
local session = create_session(user.id) 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 self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})} return {redirect_to = self:url_for("user", {username = username})}
end) end)
@ -233,10 +281,9 @@ app:get("user_signup", "/signup", function(self)
return {redirect_to = self:url_for("user", {username = user.username})} return {redirect_to = self:url_for("user", {username = user.username})}
end end
end end
if self.session.flash then
self.err = self.session.flash.error self.page_title = "sign up"
self.session.flash = {}
end
return {render = "user.signup"} return {render = "user.signup"}
end) end)
@ -253,22 +300,22 @@ app:post("user_signup", "/signup", function(self)
local password2 = self.params.password2 local password2 = self.params.password2
local user = Users:find({username = username}) local user = Users:find({username = username})
if user then 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")} return {redirect_to = self:url_for("user_signup")}
end end
if not validate_username(username) then 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")} return {redirect_to = self:url_for("user_signup")}
end end
if not validate_password(password) then 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")} return {redirect_to = self:url_for("user_signup")}
end end
if password ~= password2 then 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")} return {redirect_to = self:url_for("user_signup")}
end end
@ -279,7 +326,7 @@ app:post("user_signup", "/signup", function(self)
}) })
local session = create_session(new_user.id) 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 self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})} return {redirect_to = self:url_for("user", {username = username})}
end) end)
@ -300,7 +347,7 @@ app:post("confirm_user", "/confirm_user/:user_id", function (self)
if not user then if not user then
return {status = 403} return {status = 403}
end end
if not user:is_admin() then if not user:is_mod() then
return {status = 403} return {status = 403}
end end
local target_user = Users:find(self.params.user_id) local target_user = Users:find(self.params.user_id)
@ -315,4 +362,64 @@ app:post("confirm_user", "/confirm_user/:user_id", function (self)
return {redirect_to = self:url_for("user", {username = target_user.username})} return {redirect_to = self:url_for("user", {username = target_user.username})}
end) end)
return app 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,6 +1,8 @@
local config = require("lapis.config") local config = require("lapis.config")
local secrets = require("secrets")
config("development", { config({"development", "production"}, {
port = 8080,
server = "nginx", server = "nginx",
code_cache = "off", code_cache = "off",
num_workers = "1", num_workers = "1",
@ -10,3 +12,15 @@ config("development", {
secret = "SUPER SECRET", secret = "SUPER SECRET",
session_name = "porom_session", session_name = "porom_session",
}) })
config("production", {
code_cache = "on",
logging = {
queries = false,
},
secret = secrets.key,
sqlite = {
database = "db.prod.sqlite"
},
session_name = "porom_session_s"
})

View File

@ -3,7 +3,37 @@ local Constants = {}
Constants.PermissionLevel = { Constants.PermissionLevel = {
GUEST = 0, GUEST = 0,
USER = 1, USER = 1,
ADMIN = 2, MODERATOR = 2,
SYSTEM = 3,
ADMIN = 4,
}
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 Constants.BCRYPT_ROUNDS = 10

View File

@ -4,6 +4,17 @@ local constants = require("constants")
local alphabet = "-_@0123456789abcdefghijklmnopqrstuvwABCDEFGHIJKLMNOPQRSTUVWXYZ" 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 function create_admin()
local username = "admin" local username = "admin"
local root_count = models.Users:count("username = ?", username) local root_count = models.Users:count("username = ?", username)
@ -29,4 +40,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.") print("Admin account created, use \"admin\" as the login and \"" .. password .. "\" as the password. This will only be shown once.")
end end
create_admin() 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()

13
docker-compose.yaml Normal file
View File

@ -0,0 +1,13 @@
# Generate a random secret key
# export PROD_SECRET_KEY=$(openssl rand -hex 32)
# Start the container
# docker-compose up
version: "3"
services:
porom:
build:
context: .
args:
- PROD_SECRET_KEY=${PROD_SECRET_KEY}
ports:
- "8080:8080"

36
dockerfile Normal file
View File

@ -0,0 +1,36 @@
# HOW TO:
#
# Generate a random secret key & build the Docker image
# ```sh
# SECRET_KEY=$(openssl rand -hex 32) docker build --build-arg PROD_SECRET_KEY="$SECRET_KEY" -t porom:latest .
# ```
#
# Then run the container
# ```sh
# docker run -d -p 8080:8080 --name porom porom:latest
# ```
#
FROM openresty/openresty:alpine-fat
COPY ./nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY . /usr/local/openresty/nginx/html
WORKDIR /usr/local/openresty/nginx/html
RUN apk add --no-cache \
make \
git \
make \
gcc \
g++ \
musl-dev \
libffi-dev \
openssl-dev \
sqlite-dev \
imagemagick-dev \
lua5.1 \
lua5.1-dev
RUN eval "$(luarocks --lua-version 5.1 path)"
RUN luarocks --lua-version 5.1 build --only-deps
ARG PROD_SECRET_KEY
RUN echo "return { key = \"${PROD_SECRET_KEY}\",}" > /usr/local/openresty/nginx/html/secrets.lua
EXPOSE 8080
RUN chmod +x /usr/local/openresty/nginx/html/start.sh
ENTRYPOINT ["/usr/local/openresty/nginx/html/start.sh", "production"]

View File

@ -1,15 +1,5 @@
local babycode = {} local babycode = {}
local _escape_html = function(text)
return text:gsub("[&<>\"']", {
["&"] = "&amp;",
["<"] = "&lt;",
[">"] = "&gt;",
['"'] = "&quot;",
["'"] = "&#39;"
})
end
---renders babycode to html ---renders babycode to html
---@param s string input babycode ---@param s string input babycode
---@param escape_html fun(s: string): string function that escapes html ---@param escape_html fun(s: string): string function that escapes html
@ -21,7 +11,8 @@ function babycode.to_html(s, escape_html)
local code_count = 0 local code_count = 0
local text = s:gsub("%[code%](.-)%[/code%]", function(code) local text = s:gsub("%[code%](.-)%[/code%]", function(code)
code_count = code_count + 1 code_count = code_count + 1
code_blocks[code_count] = code -- strip leading and trailing newlines, preserve others
code_blocks[code_count] = code:gsub("^%s*(.-)%s*$", "%1")
return "\1CODE:"..code_count.."\1" return "\1CODE:"..code_count.."\1"
end) end)
@ -48,14 +39,14 @@ function babycode.to_html(s, escape_html)
return url return url
end) end)
-- normalize newlines, replace them with <br>
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
-- replace code block placeholders back with their original contents -- replace code block placeholders back with their original contents
text = text:gsub("\1CODE:(%d+)\1", function(n) text = text:gsub("\1CODE:(%d+)\1", function(n)
return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>" return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>"
end) end)
-- finally, normalize newlines replace them with <br>
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
return text return text
end end

View File

@ -48,4 +48,19 @@ return {
db.query("CREATE INDEX idx_topics_slug ON topics(slug)") db.query("CREATE INDEX idx_topics_slug ON topics(slug)")
db.query("CREATE INDEX idx_threads_slug ON threads(slug)") db.query("CREATE INDEX idx_threads_slug ON threads(slug)")
end, 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
} }

View File

@ -12,12 +12,24 @@ function Users_mt:is_admin()
return self.permission == constants.PermissionLevel.ADMIN return self.permission == constants.PermissionLevel.ADMIN
end 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() function Users_mt:is_logged_in_guest()
return self:is_guest() and true return self:is_guest() and true
end end
function Users_mt:is_default_avatar() 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 end
local ret = { local ret = {

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",
"bcrypt",
"luaossl",
}
build = {
type = "none"
}

368
sass/style.scss Normal file
View File

@ -0,0 +1,368 @@
/* src: */
@use "sass:color";
$accent_color: #c1ceb1;
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
$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-base {
cursor: default;
color: black;
font-size: 0.9rem;
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: sans-serif;
margin: 20px 100px;
background-color: $main_bg;
}
.big {
font-size: 1.8rem;
}
#topnav {
@include navbar($accent_color);
justify-content: space-between;
align-items: center;
}
#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 {
padding-right: 30px;
font-size: 1.5rem;
font-weight: bold;
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;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 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: 5px 20px;
}
.user-posts {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas:
"user-page-usercard user-posts-container";
border: 2px outset $dark2;
}
.user-page-usercard {
grid-area: user-page-usercard;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.user-posts-container {
grid-area: user-posts-container;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"post-info"
"post-content";
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
padding-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: $accent_color;
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;
}
.contain-svg > svg {
height: 50%;
width: 50%;
}
.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;
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
}
.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;
}

3
secrets.lua.example Normal file
View File

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

28
start.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
start() {
lapis serve
}
first_launch() {
echo "Setting up for the first time"
touch ".first_launch.$LAPIS_ENVIRONMENT"
lua5.1 schema.lua
lapis migrate
lua5.1 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 ".first_launch.$LAPIS_ENVIRONMENT" ]; then
first_launch
fi
start

BIN
static/avatars/default.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

366
static/style.css Normal file
View File

@ -0,0 +1,366 @@
/* src: */
.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;
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
padding: 5px 20px;
margin: 10px 0;
}
body {
font-family: sans-serif;
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: center;
}
#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 {
padding-right: 30px;
font-size: 1.5rem;
font-weight: bold;
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;
display: flex;
flex-direction: column;
align-items: center;
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;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 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: 5px 20px;
}
.user-posts {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "user-page-usercard user-posts-container";
border: 2px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.user-page-usercard {
grid-area: user-page-usercard;
display: flex;
flex-direction: column;
align-items: center;
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-posts-container {
grid-area: user-posts-container;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 0.2fr 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content";
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
padding-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: #c1ceb1;
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 > svg {
height: 50%;
width: 50%;
}
.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;
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
}
.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);
}

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

View File

@ -2,6 +2,7 @@ local util = {}
local magick = require("magick") local magick = require("magick")
local db = require("lapis.db") local db = require("lapis.db")
local html_escape = require("lapis.html").escape local html_escape = require("lapis.html").escape
local constants = require("constants")
local Avatars = require("models").Avatars local Avatars = require("models").Avatars
local Users = require("models").Users local Users = require("models").Users
@ -14,19 +15,25 @@ util.TransientUser = {
is_admin = function (self) is_admin = function (self)
return false return false
end, end,
is_mod = function (self)
return false
end,
is_guest = function (self) is_guest = function (self)
return true return true
end, end,
is_system = function (self)
return false
end,
is_logged_in_guest = function (self) is_logged_in_guest = function (self)
return false return false
end, end,
is_logged_in = function (self)
return false
end,
username = "Deleted User", username = "Deleted User",
} }
function util.get_user_avatar_url(req, user) 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 return Avatars:find(user.avatar_id).file_path
end end
@ -62,6 +69,29 @@ function util.validate_and_create_image(input_image, filename)
return true return true
end end
function util.destroy_avatar(avatar_id)
if avatar_id == 1 then
print("won't delete default avatar")
return
end
local avatar = Avatars:find(avatar_id)
if not avatar then
return
end
local file_path = "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.get_logged_in_user(req) function util.get_logged_in_user(req)
if req.session.session_key == nil then if req.session.session_key == nil then
return nil return nil
@ -116,7 +146,6 @@ function util.create_post(thread_id, user_id, content)
local revision = PostHistory:create({ local revision = PostHistory:create({
post_id = post.id, post_id = post.id,
user_id = user_id,
content = bb_content, content = bb_content,
is_initial_revision = true, is_initial_revision = true,
}) })
@ -127,4 +156,47 @@ function util.create_post(thread_id, user_id, content)
return post return post
end end
return util 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
return util

View File

@ -2,9 +2,16 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Porom</title> <% if page_title then %>
<title>Porom - <%= page_title %></title>
<% else %>
<title>Porom</title>
<% end %>
<% math.randomseed(os.time()) %>
<link rel="stylesheet" href="<%= "/static/style.css?" .. math.random(1, 100) %>">
</head> </head>
<body> <body>
<% render("views.common.topnav") -%>
<% content_for("inner") %> <% content_for("inner") %>
</body> </body>
</html> </html>

View File

@ -0,0 +1,11 @@
<details>
<summary>Supported babycode tags</summary>
<ul>
<li>[b]<b>bold</b>[/b]</li>
<li>[i]<i>italic</i>[/i]</li>
<li>[s]<del>strikethrough</del>[/s]</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>
</ul>
</details>

View File

@ -0,0 +1,13 @@
<%
local class = "infobox " .. constants.InfoboxHTMLClass[kind]
local icon = constants.InfoboxIcons[kind]
%>
<div class="<%= class %>">
<span>
<div class="infobox-icon-container">
<% render(icon) %>
</div>
<%= msg %>
</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>

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

@ -0,0 +1,18 @@
<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>
<% 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,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

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

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

@ -0,0 +1,26 @@
<div class="post" 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">
</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 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>
</div>
<div class="post-content">
<%- post.content %>
</div>
</div>
</div>

View File

@ -1,20 +1,25 @@
<% for _, post in ipairs(posts) do %> <% local is_locked = ntob(thread.is_locked) %>
<div> <main>
<img src="<%= post.avatar_path or "/avatars/default.webp" %>"> <nav class="darkbg">
<div><%= post.username %></div> <h1 class="thread-title"><%= thread.title %></h1>
<div><p><%- post.content %></p></div> <span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a></span>
</div> </nav>
<% end %> <% for i, post in ipairs(posts) do %>
<% render("views.threads.post", {post = post, is_latest = i == #posts}) %>
<% end %>
</main>
<% if not user:is_guest() then %> <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 not me:is_guest() and not is_locked then %>
<h1>Respond to "<%= thread.title %>"</h1> <h1>Respond to "<%= thread.title %>"</h1>
<form method="post"> <form method="post">
<textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br> <textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br>
<input type="submit" value="Reply"> <input type="submit" value="Post reply">
</form> </form>
<% end %> <% end %>
<% if next_cursor then %>
<a href="<%= url_for('thread', {slug = thread.slug}, {cursor = next_cursor}) %>">
Older posts →
</a>
<% end %>

View File

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

View File

@ -1,25 +1,68 @@
<h1><%= topic.name %></h1> <% if infobox then %>
<h2><%= topic.description %></h2> <% render("views.common.infobox", infobox) %>
<% end %>
<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 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 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" %>">
</form>
<% end %>
</div>
</nav>
<% if #threads_list == 0 then %> <% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p> <p>There are no threads in this topic.</p>
<% end %>
<% if thread_create_error == ThreadCreateError.OK then %>
<a 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>
<% 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 %> <% else %>
<p>This topic is locked.</p> <% for _, thread in ipairs(threads_list) do %>
<% local is_stickied = ntob(thread.is_stickied) %>
<% local 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 <%= os.date("%c", 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>:
</span>
<span class="thread-info-post-preview">
<%- thread.latest_post_content %>
</span>
</div>
<div class="thread-locked-container contain-svg">
<% if is_locked then -%>
<% render("svg-icons.lock") %>
<i>Locked</i>
<% end -%>
</div>
</div>
<% end %>
<% end %> <% end %>
<% if user:is_admin() then %> <nav id="bottomnav">
<br> <% render("views.common.pagination", {page_count = pages, current_page = page}) %>
<a href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a> </nav>
<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 %>

View File

@ -1,16 +1,33 @@
<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>
<% end %>
</nav>
<% if #topic_list == 0 then %> <% if #topic_list == 0 then %>
<p>There are no topics.</p> <p>There are no topics.</p>
<% else %> <% else %>
<ul> <% for _, topic in ipairs(topic_list) do %>
<% for i, v in ipairs(topic_list) do %> <% local is_locked = ntob(topic.is_locked) %>
<li> <div class="topic">
<a href=<%= url_for("topic", {slug = v.slug}) %>><%= v.name %></a> - <%= v.description %> <div class="topic-info-container">
</li> <a 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) %>
</span>
<% 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 %>
<% 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>

View File

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

View File

@ -1,18 +1,25 @@
<h1>User settings</h1> <% local disable_avatar = me:is_logged_in_guest() %>
<% if flash_msg then %> <div class="darkbg settings-container">
<h2><%= flash_msg %></h2> <h1>User settings</h1>
<% end %> <% if infobox then %>
<form method="post" action="<%= url_for("user_set_avatar", {username = user.username}) %>" enctype="multipart/form-data"> <% render("views.common.infobox", infobox) %>
<img src="<%= avatar_url(user) %>"><br> <% end %>
<input type="file" name="avatar" accept="image/*"><br> <form class="avatar-form" method="post" action="<%= url_for("user_set_avatar", {username = me.username}) %>" enctype="multipart/form-data">
<input type="submit" value="Update avatar"> <img src="<%= avatar_url(me) %>">
<% if not user:is_default_avatar() then %> <input id="file" type="file" name="avatar" accept="image/*" required>
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = user.username}) %>"> <div>
<% end %> <input type="submit" value="Update avatar" <%= disable_avatar and "disabled=disabled" %>>
<br> <% if not me:is_default_avatar() then %>
</form> <input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
<form method="post" action=""> <% end %>
<label for="status">Status</label> </div>
<input type="text" id="status" name="status" value="<%= user.status %>" maxlength="10"><br> </form>
<input type="submit" value="Save"> <form method="post" action="">
</form> <label for="status">Status</label>
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="30">
<input type="submit" value="Save status">
</form>
<div>
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
</div>
</div>

View File

@ -1,15 +1,16 @@
<h1>Sign up</h1> <div class="darkbg login-container">
<h1>Sign up</h1>
<% if err then %> <% if infobox then %>
<h2><%= err %></h2> <% render("views.common.infobox", infobox) %>
<% end %> <% end %>
<form method="post" action="<%= url_for('user_signup') %>" enctype="multipart/form-data"> <form method="post" action="<%= url_for('user_signup') %>" enctype="multipart/form-data">
<label for="username">Username</label><br> <label for="username">Username</label><br>
<input type="text" id="username" name="username" pattern="[\w\-]{3,20}" title="3-20 characters. Only upper and lowercase letters, hyphens, and underscores" required autocomplete="username"><br> <input type="text" id="username" name="username" pattern="[\w\-]{3,20}" title="3-20 characters. Only upper and lowercase letters, hyphens, and underscores" required autocomplete="username"><br>
<label for="password">Password</label><br> <label for="password">Password</label><br>
<input type="password" id="password" name="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> <input type="password" id="password" name="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="password2">Confirm Password</label><br> <label for="password2">Confirm Password</label><br>
<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="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"> <input type="submit" value="Sign up">
</form> </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,73 @@
<% if just_logged_in then %> <% if infobox then %>
<h1>Logged in successfully.</h1> <% render("views.common.infobox", pop_infobox) %>
<% end %> <% end %>
<img src="<%= avatar_url(user) %>"> <div class="darkbg">
<h1><%= user.username %></h1> <h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>
<div>
User permission: <i><%= PermissionLevelString[user.permission] %></i>
</div>
<% 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 class="warn" type="submit" value="Log out">
</form>
</div>
<% end %>
</div>
<% --[[ duplicating code, maybe i'll refactor the post subview later to work anywhere <clown emoji>]] %>
<% for i, post in ipairs(latest_posts) do %>
<div class="user-posts">
<div class="user-page-usercard">
<img class="avatar" src="<%= avatar_url(user) %>">
<b class="big"><%= user.username %></b>
<em class="user-status"><%= user.status %></em>
</div>
<div class="user-posts-container">
<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) %>
<% else -%>
Posted in <%= post.thread_title %> at <%= os.date("%c", post.created_at) %>
<% end -%>
</i></a></div>
</div>
<div class="post-content">
<%- post.content %>
</div>
</div>
</div>
<% end %>
<% if user:is_guest() and user_is_me then %> <% 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> <h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
<% end %> <% end %>
<% if user_is_me then %>
<form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>"> <% if me:is_mod() and not user:is_system() then %>
<input type="submit" value="Log out"> <div class="darkbg">
</form> <h1>Moderator controls</h2>
<% end %> <% if user:is_guest() then %>
<% 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>
<p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p> <form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<form method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>"> <input type="submit" value="Confirm user">
<input type="submit" value="Confirm user"> </form>
</form> <% else %> <% --[[ user is not guest ]] %>
<% 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>
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", 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 %>
</div>
<% end %> <% end %>