Compare commits

...

10 Commits

Author SHA1 Message Date
94c735b913
add readme 2025-05-18 20:00:01 +03:00
785eafd646
add bbcode support 2025-05-18 19:55:07 +03:00
4039d6d299
add threads n posts 2025-05-18 17:55:03 +03:00
f5485702a8
add topics 2025-05-18 15:56:29 +03:00
86b568d0f4
move validate session to util module 2025-05-18 13:18:56 +03:00
836ad72521
add avatars 2025-05-18 11:39:12 +03:00
9c327957d9
add user confirmation by admins 2025-05-18 06:55:21 +03:00
ac51e5c0e8
starting users 2025-05-18 05:41:26 +03:00
03a20128f7
schema 2025-05-17 17:12:23 +03:00
91d4fa59f3
cfg 2025-05-17 16:20:47 +03:00
26 changed files with 1143 additions and 3 deletions

4
.gitignore vendored
View File

@ -1,2 +1,6 @@
logs/
nginx.conf.compiled
db.*.sqlite
.vscode/
.local/
static/

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Porom
porous forum
# deps
this is all off the top of my head so if you try to run it got help you
- lapis
- lsqlite3
- [magick](https://github.com/leafo/magick)
- bcrypt
i think thats it

18
app.lua
View File

@ -1,6 +1,24 @@
local lapis = require("lapis")
local app = lapis.Application()
local util = require("util")
app:enable("etlua")
app.layout = require "views.base"
local function inject_methods(req)
req.avatar_url = util.get_user_avatar_url
req.ntob = function(_, v)
return util.ntob(v)
end
end
app:before_filter(inject_methods)
app:include("apps.users", {path = "/user"})
app:include("apps.topics", {path = "/topics"})
app:include("apps.threads", {path = "/threads"})
app:get("/", function()
return "Welcome to Lapis " .. require("lapis.version")
end)

119
apps/threads.lua Normal file
View File

@ -0,0 +1,119 @@
local app = require("lapis").Application()
local lapis_util = require("lapis.util")
local db = require("lapis.db")
local util = require("util")
local models = require("models")
local Topics = models.Topics
local Threads = models.Threads
local Posts = models.Posts
app:get("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self)
if not user then
self.session.flash = {error = "You must be logged in to perform this action."}
return {redirect_to = self:url_for("user_login")}
end
local all_topics = db.query("select * from topics limit 25;")
if #all_topics == 0 then
return "how did you get here?"
end
self.all_topics = all_topics
return {render = "threads.create"}
end)
app:post("thread_create", "/create", function(self)
local user = util.get_logged_in_user(self)
if not user then
self.session.flash = {error = "You must be logged in to perform this action."}
return {redirect_to = self:url_for("user_login")}
end
local topic = Topics:find(self.params.topic_id)
if not topic then
return {redirect_to = self:url_for("topics")}
end
local title = lapis_util.trim(self.params.title)
local time = os.time()
local slug = lapis_util.slugify(title) .. "-" .. time
local post_content = self.params.initial_post
local thread = Threads:create({
topic_id = topic.id,
user_id = user.id,
title = title,
slug = slug,
created_at = time,
})
local post = util.create_post(thread.id, user.id, post_content)
if not post then
return {redirect_to = self:url_for("topics")}
end
return {redirect_to = self:url_for("thread", {slug = slug})}
end)
app:get("thread", "/:slug", function(self)
local thread = Threads:find({
slug = self.params.slug
})
if not thread then
return {status = 404}
end
self.thread = thread
local posts = db.query([[
SELECT
posts.id, post_history.content, users.username, avatars.file_path AS avatar_path
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
WHERE
posts.thread_id = ? and posts.id > ?
ORDER BY
posts.created_at ASC
LIMIT 20
]], thread.id, tonumber(self.params.cursor or 0))
self.user = util.get_logged_in_user_or_transient(self)
self.posts = posts
self.next_cursor = #posts > 0 and posts[#posts].id or nil
return {render = "threads.thread"}
end)
app:post("thread", "/:slug", function(self)
local thread = Threads:find({
slug = self.params.slug
})
if not thread then
return {redirect_to = self:url_for("all_topics")}
end
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("all_topics")}
end
if user:is_guest() then
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
if util.is_thread_locked(thread) and not user:is_admin() then
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
local post_content = self.params.post_content
local post = util.create_post(thread.id, user.id, post_content)
if not post then
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
end)
return app

125
apps/topics.lua Normal file
View File

@ -0,0 +1,125 @@
local app = require("lapis").Application()
local lapis_util = require("lapis.util")
local db = require("lapis.db")
local constants = require("constants")
local util = require("util")
local models = require("models")
local Users = models.Users
local Avatars = models.Avatars
local Topics = models.Topics
local Threads = models.Threads
local ThreadCreateError = {
OK = 0,
GUEST = 1,
LOGGED_OUT = 2,
TOPIC_LOCKED = 3,
}
app:get("all_topics", "", function(self)
self.topic_list = db.query("select * from topics limit 25;")
self.user = util.get_logged_in_user(self) or util.TransientUser
return {render = "topics.topics"}
end)
app:get("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser
if not user:is_admin() then
return {status = 403}
end
return {render = "topics.create"}
end)
app:post("topic_create", "/create", function(self)
local user = util.get_logged_in_user(self) or util.TransientUser
if not user:is_admin() then
return {redirect_to = "all_topics"}
end
local topic_name = lapis_util.trim(self.params.name)
local topic_description = self.params.description
local time = os.time()
local slug = lapis_util.slugify(topic_name) .. "-" .. time
local topic = Topics:create({
name = topic_name,
description = topic_description,
slug = slug,
})
return {redirect_to = self:url_for("all_topics")}
end)
app:get("topic", "/:slug", function(self)
local topic = Topics:find({
slug = self.params.slug
})
if not topic then
return {status = 404}
end
self.topic = topic
self.threads_list = Threads:select(db.clause({
topic_id = topic.id
}))
local user = util.get_logged_in_user_or_transient(self)
print(topic.is_locked, type(topic.is_locked))
self.user = user
self.ThreadCreateError = ThreadCreateError
self.thread_create_error = ThreadCreateError.OK
if user:is_logged_in_guest() then
self.thread_create_error = ThreadCreateError.GUEST
elseif user:is_guest() then
self.thread_create_error = ThreadCreateError.LOGGED_OUT
elseif util.ntob(topic.is_locked) and not user:is_admin() then
self.thread_create_error = ThreadCreateError.TOPIC_LOCKED
end
return {render = "topics.topic"}
end)
app:get("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(self)
if not user:is_admin() then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
local topic = Topics:find({
slug = self.params.slug
})
if not topic then
return {redirect_to = self:url_for("all_topics")}
end
self.topic = topic
return {render = "topics.edit"}
end)
app:post("topic_edit", "/:slug/edit", function(self)
local user = util.get_logged_in_user_or_transient(self)
if not user:is_admin() then
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end
local topic = Topics:find({
slug = self.params.slug
})
if not topic then
return {redirect_to = self:url_for("all_topics")}
end
local name = self.params.name or topic.name
local description = self.params.description or topic.description
local is_locked = topic.is_locked
if self.params.is_locked ~= nil then
is_locked = util.form_bool_to_sqlite(self.params.is_locked)
end
topic:update({
name = name,
description = description,
is_locked = is_locked,
})
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
end)
return app

318
apps/users.lua Normal file
View File

@ -0,0 +1,318 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local constants = require("constants")
local util = require("util")
local bcrypt = require("bcrypt")
local rand = require("openssl.rand")
local models = require("models")
local Users = models.Users
local Sessions = models.Sessions
local Avatars = models.Avatars
local function authenticate_user(user, password)
return bcrypt.verify(password, user.password_hash)
end
local function create_session_key()
return rand.bytes(16):gsub(".", function(c) return string.format("%02x", string.byte(c)) end)
end
local function create_session(user_id)
local days = 30
local expires_at = os.time() + (days * 24 * 60 * 60)
return Sessions:create({
key = create_session_key(),
user_id = user_id,
expires_at = expires_at,
})
end
local function validate_password(password)
if #password < 10 or password:match("%s") then
return false
end
if #password > 255 then
return false
end
local r = password:match("%u+") and
password:match("%l+") and
password:match("%d+") and
password:match("%p+")
return r ~= nil and true
end
local function validate_username(username)
if #username < 3 or #username > 20 then
return false
end
return username:match("^[%w_-]+$") and true
end
local function validate_url(url)
return url:match('^https?://.+$') and true
end
app:get("user", "/:username", function(self)
local user = Users:find({username = self.params.username})
if not user then
return {status = 404}
end
if self.session.flash ~= nil and self.session.flash.just_logged_in then
self.just_logged_in = true
self.session.flash = {}
end
-- local me = validate_session(self.session.session_key) or TransientUser
local me = util.get_logged_in_user(self) or util.TransientUser
self.user = user
self.me = me
self.user_is_me = me.id == user.id
if user.permission == constants.PermissionLevel.GUEST then
if not (self.user_is_me or me:is_admin()) then
return {status = 404}
end
end
return {render = "user.user"}
end)
app:post("user_clear_avatar", "/:username/clear_avatar", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
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
target_user:update({
avatar_id = db.NULL,
})
self.session.flash = {success = true, msg = "Avatar cleared."}
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:post("user_set_avatar", "/:username/set_avatar", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
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 file = self.params.avatar
if not file then
self.session.flash = {error = "Something went wrong. Try again later."}
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
local time = os.time()
local filename = "u" .. target_user.id .. "d" .. time .. ".webp"
local proxied_filename = "/avatars/" .. filename
local save_path = "static" .. proxied_filename
local res = util.validate_and_create_image(file.content, save_path)
if not res then
self.session.flash = {error = "Something went wrong. Try again later."}
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end
self.session.flash = {success = true, msg = "Avatar updated."}
local avatar = Avatars:create({
file_path = proxied_filename,
uploaded_at = time,
})
target_user:update({
avatar_id = avatar.id
})
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:get("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
return {redirect_to = self:url_for("user_login")}
end
local target_user = Users:find({username = self.params.username})
if me.id ~= target_user.id then
return {redirect_to = self:url_for("user", {username = self.params.username})}
end
if self.session.flash then
local flash = self.session.flash
self.session.flash = nil
if flash.success then
self.flash_msg = flash.msg
elseif flash.error then
self.flash_msg = flash.error
end
end
self.user = target_user
return {render = "user.settings"}
end)
app:post("user_settings", "/:username/settings", function(self)
local me = util.get_logged_in_user(self)
if me == nil then
self.session.flash = {error = "You must be logged in to perform this action."}
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 status = self.params.status:sub(1, 100)
target_user:update({
status = status,
})
self.session.flash = {
success = true,
msg = "Settings updated."
}
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
end)
app:get("user_login", "/login", function(self)
if self.session.session_key then
local user = util.get_logged_in_user(self)
if user ~= nil then
return {redirect_to = self:url_for("user", {username = user.username})}
end
end
if self.session.flash then
self.err = self.session.flash.error
self.session.flash = {}
end
return {render = "user.login"}
end)
app:post("user_login", "/login", function(self)
if self.session.session_key then
local user = util.get_logged_in_user(self)
if user ~= nil then
return {redirect_to = self:url_for("user", {username = user.username})}
end
end
local username = self.params.username
local password = self.params.password
local user = Users:find({username = username})
if not user then
self.session.flash = {error = "Invalid username or password"}
return {redirect_to = self:url_for("user_login")}
end
if not authenticate_user(user, password) then
self.session.flash = {error = "Invalid username or password"}
return {redirect_to = self:url_for("user_login")}
end
local session = create_session(user.id)
self.session.flash = {just_logged_in = true}
self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})}
end)
app:get("user_signup", "/signup", function(self)
if self.session.session_key then
local user = util.get_logged_in_user(self)
if user ~= nil then
return {redirect_to = self:url_for("user", {username = user.username})}
end
end
if self.session.flash then
self.err = self.session.flash.error
self.session.flash = {}
end
return {render = "user.signup"}
end)
app:post("user_signup", "/signup", function(self)
if self.session.session_key then
local user = util.get_logged_in_user(self)
if user ~= nil then
return {redirect_to = self:url_for("user", {username = user.username})}
end
end
local username = self.params.username
local password = self.params.password
local password2 = self.params.password2
local user = Users:find({username = username})
if user then
self.session.flash = {error = "Username '" .. username .. "' is already taken."}
return {redirect_to = self:url_for("user_signup")}
end
if not validate_username(username) then
self.session.flash = {error = "Username must be 3-20 characters with only upper and lowercase letters, hyphens, and underscores."}
return {redirect_to = self:url_for("user_signup")}
end
if not validate_password(password) then
self.session.flash = {error = "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces."}
return {redirect_to = self:url_for("user_signup")}
end
if password ~= password2 then
self.session.flash = {error = "Passwords do not match."}
return {redirect_to = self:url_for("user_signup")}
end
local new_user = Users:create({
username = username,
password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS),
permission = constants.PermissionLevel.GUEST,
})
local session = create_session(new_user.id)
self.session.flash = {just_logged_in = true}
self.session.session_key = session.key
return {redirect_to = self:url_for("user", {username = username})}
end)
app:post("user_logout", "/logout", function (self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("user_login")}
end
local session = Sessions:find({key = self.session.session_key})
session:delete()
return {redirect_to = self:url_for("user_login")}
end)
app:post("confirm_user", "/confirm_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.permission > constants.PermissionLevel.GUEST then
return {status = 404}
end
target_user:update({permission = constants.PermissionLevel.USER, confirmed_on = os.time()})
return {redirect_to = self:url_for("user", {username = target_user.username})}
end)
return app

View File

@ -3,5 +3,10 @@ local config = require("lapis.config")
config("development", {
server = "nginx",
code_cache = "off",
num_workers = "1"
num_workers = "1",
sqlite = {
database = "db.dev.sqlite"
},
secret = "SUPER SECRET",
session_name = "porom_session",
})

11
constants.lua Normal file
View File

@ -0,0 +1,11 @@
local Constants = {}
Constants.PermissionLevel = {
GUEST = 0,
USER = 1,
ADMIN = 2,
}
Constants.BCRYPT_ROUNDS = 10
return Constants

32
create_default_admin.lua Normal file
View File

@ -0,0 +1,32 @@
local bcrypt = require("bcrypt")
local models = require("models")
local constants = require("constants")
local alphabet = "-_@0123456789abcdefghijklmnopqrstuvwABCDEFGHIJKLMNOPQRSTUVWXYZ"
local function create_admin()
local username = "admin"
local root_count = models.Users:count("username = ?", username)
if root_count ~= 0 then
print("admin account already exists.")
return
end
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)
models.Users:create({
username = username,
password_hash = hash,
permission = constants.PermissionLevel.ADMIN,
})
print("Admin account created, use \"admin\" as the login and \"" .. password .. "\" as the password. This will only be shown once.")
end
create_admin()

62
lib/babycode.lua Normal file
View File

@ -0,0 +1,62 @@
local babycode = {}
local _escape_html = function(text)
return text:gsub("[&<>\"']", {
["&"] = "&amp;",
["<"] = "&lt;",
[">"] = "&gt;",
['"'] = "&quot;",
["'"] = "&#39;"
})
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
-- don't want to process bbcode embedded into a code block
local code_blocks = {}
local code_count = 0
local text = s:gsub("%[code%](.-)%[/code%]", function(code)
code_count = code_count + 1
code_blocks[code_count] = code
return "\1CODE:"..code_count.."\1"
end)
-- replace `[url=https://example.com]Example[/url] tags
text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
return '<a href="'..escape_html(url)..'">'..escape_html(label)..'</a>'
end)
-- replace `[url]https://example.com[/url] tags
text = text:gsub("%[url%]([^%]]+)%[/url%]", function(url)
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
end)
-- bold, italics, strikethrough
text = text:gsub("%[b%](.-)%[/b%]", "<strong>%1</strong>")
text = text:gsub("%[i%](.-)%[/i%]", "<em>%1</em>")
text = text:gsub("%[s%](.-)%[/s%]", "<del>%1</del>")
-- replace loose links
text = text:gsub("(https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/])", function(url)
if not text:find('<a[^>]*>'..url..'</a>') then
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
end
return url
end)
-- replace code block placeholders back with their original contents
text = text:gsub("\1CODE:(%d+)\1", function(n)
return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>"
end)
-- finally, normalize newlines replace them with <br>
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
return text
end
return babycode

51
migrations.lua Normal file
View File

@ -0,0 +1,51 @@
local db = require("lapis.db")
local schema = require("lapis.db.schema")
local types = schema.types
return {
[1] = function ()
schema.create_table("sessions", {
{"id", types.integer{primary_key = true}},
{"key", types.text{unique = true}},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
{"expires_at", types.integer},
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
})
db.query("CREATE INDEX sessions_user_id ON sessions(user_id)")
db.query("CREATE INDEX session_keys ON sessions(key)")
end,
[2] = function ()
schema.add_column("users", "confirmed_on", types.integer{null = true})
end,
[3] = function ()
schema.add_column("users", "status", types.text{null = true, default=""})
schema.create_table("avatars", {
{"id", types.integer{primary_key = true}},
{"file_path", types.text{unique = true}},
{"uploaded_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
})
schema.add_column("users", "avatar_id", "REFERENCES avatars(id) ON DELETE SET NULL")
end,
[4] = function ()
schema.add_column("topics", "description", types.text{default=""})
-- topic locked = no new threads can be created in the topic, but posts can be made in threads
-- thread locked = no new posts can be created in the thread, existing posts can not be edited
-- admins bypass both restrictions
schema.add_column("topics", "is_locked", "BOOLEAN DEFAULT FALSE")
schema.add_column("threads", "is_locked", "BOOLEAN DEFAULT FALSE")
-- will appear on top of non-stickied threads in topic view
schema.add_column("threads", "is_stickied", "BOOLEAN DEFAULT FALSE")
end,
[5] = function ()
db.query("CREATE INDEX idx_posts_thread ON posts(thread_id, created_at, id)")
db.query("CREATE INDEX idx_users_avatar ON users(avatar_id)")
db.query("CREATE INDEX idx_topics_slug ON topics(slug)")
db.query("CREATE INDEX idx_threads_slug ON threads(slug)")
end,
}

View File

@ -1,2 +1,34 @@
local autoload = require("lapis.util").autoload
return autoload("models")
local Model = require("lapis.db.model").Model
local constants = require("constants")
local Users, Users_mt = Model:extend("users")
function Users_mt:is_guest()
return self.permission == constants.PermissionLevel.GUEST
end
function Users_mt:is_admin()
return self.permission == constants.PermissionLevel.ADMIN
end
function Users_mt:is_logged_in_guest()
return self:is_guest() and true
end
function Users_mt:is_default_avatar()
return self.avatar_id == nil
end
local ret = {
Users = Users,
Topics = Model:extend("topics"),
Threads = Model:extend("threads"),
Posts = Model:extend("posts"),
PostHistory = Model:extend("post_history"),
Sessions = Model:extend("sessions"),
Avatars = Model:extend("avatars"),
}
return ret

View File

@ -32,5 +32,10 @@ http {
location /favicon.ico {
alias static/favicon.ico;
}
location /avatars {
alias static/avatars;
expires 1y;
}
}
}

48
schema.lua Normal file
View File

@ -0,0 +1,48 @@
local schema = require("lapis.db.schema")
local db = require("lapis.db")
local types = schema.types
schema.create_table("users", {
{"id", types.integer{primary_key = true}},
{"username", types.text{unique = true, null = false}},
{"password_hash", types.text{null = false}},
{"permission", types.integer{default = 0}},
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"}
})
schema.create_table("topics", {
{"id", types.integer{primary_key = true}},
{"name", types.text{null = false}},
{"slug", types.text{null = false, unique = true}}
})
schema.create_table("threads", {
{"id", types.integer{primary_key = true}},
{"topic_id", "INTEGER REFERENCES topics(id) ON DELETE CASCADE"},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE SET NULL"},
{"title", types.text{null = false}},
{"slug", types.text{null = false, unique = true}},
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
})
schema.create_table("posts", {
{"id", types.integer{primary_key = true}},
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE SET NULL"},
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
{"current_revision_id", "INTEGER REFERENCES post_history(id)"},
})
schema.create_table("post_history", {
{"id", types.integer{primary_key = true}},
{"post_id", "INTEGER REFERENCES posts(id) ON DELETE CASCADE"},
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
{"content", types.text{null = false}},
{"edited_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
{"is_initial_revision", "BOOLEAN DEFAULT FALSE"}
})
db.query("CREATE INDEX idx_threads_topic_id ON threads(topic_id)")
db.query("CREATE INDEX idx_posts_thread_id ON posts(thread_id)")
db.query("CREATE INDEX idx_post_history_post_id ON post_history(post_id)")

130
util.lua Normal file
View File

@ -0,0 +1,130 @@
local util = {}
local magick = require("magick")
local db = require("lapis.db")
local html_escape = require("lapis.html").escape
local Avatars = require("models").Avatars
local Users = require("models").Users
local Posts = require("models").Posts
local PostHistory = require("models").PostHistory
local babycode = require("lib.babycode")
util.TransientUser = {
is_admin = function (self)
return false
end,
is_guest = function (self)
return true
end,
is_logged_in_guest = function (self)
return false
end,
username = "Deleted 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
end
function util.validate_and_create_image(input_image, filename)
local img = magick.load_image_from_blob(input_image)
if not img then
return false
end
img:strip()
img:set_gravity("CenterGravity")
local width, height = img:get_width(), img:get_height()
local min_dim = math.min(width, height)
if min_dim > 256 then
local ratio = 256.0 / min_dim
local new_w, new_h = width * ratio, height * ratio
img:resize(new_w, new_h)
end
width, height = img:get_width(), img:get_height()
local crop_size = math.min(width, height)
local x_offset = (width - crop_size) / 2
local y_offset = (height - crop_size) / 2
img:crop(crop_size, crop_size, x_offset, y_offset)
img:set_format("webp")
img:set_quality(85)
img:write(filename)
img:destroy()
return true
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)
db.query("BEGIN")
local post = Posts:create({
thread_id = thread_id,
user_id = user_id,
current_revision_id = db.NULL,
})
local bb_content = babycode.to_html(content, html_escape)
local revision = PostHistory:create({
post_id = post.id,
user_id = user_id,
content = bb_content,
is_initial_revision = true,
})
post:update({current_revision_id = revision.id})
db.query("COMMIT")
return post
end
return util

10
views/base.etlua Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Porom</title>
</head>
<body>
<% content_for("inner") %>
</body>
</html>

View File

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

View File

@ -0,0 +1,20 @@
<% for _, post in ipairs(posts) do %>
<div>
<img src="<%= post.avatar_path or "/avatars/default.webp" %>">
<div><%= post.username %></div>
<div><p><%- post.content %></p></div>
</div>
<% end %>
<% if not user:is_guest() then %>
<h1>Respond to "<%= thread.title %>"</h1>
<form method="post">
<textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br>
<input type="submit" value="Reply">
</form>
<% end %>
<% if next_cursor then %>
<a href="<%= url_for('thread', {slug = thread.slug}, {cursor = next_cursor}) %>">
Older posts →
</a>
<% end %>

View File

@ -0,0 +1,6 @@
<h1>Create topic</h1>
<form method="post">
<input type="text" name="name" id="name" placeholder="Topic name" required><br>
<textarea id="description" name="description" placeholder="Topic description" required></textarea><br>
<input type="submit" value="Create topic">
</form>

12
views/topics/edit.etlua Normal file
View File

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

25
views/topics/topic.etlua Normal file
View File

@ -0,0 +1,25 @@
<h1><%= topic.name %></h1>
<h2><%= topic.description %></h2>
<% if #threads_list == 0 then %>
<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 %>
<p>This topic is locked.</p>
<% end %>
<% if user:is_admin() then %>
<br>
<a href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
<form method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
<input type="hidden" name="is_locked" value="<%= not ntob(topic.is_locked) %>">
<p><%= "This topic is " .. (ntob(topic.is_locked) and "" or "un") .. "locked." %></p>
<input type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock" or "Lock" %>">
</form>
<% end %>

16
views/topics/topics.etlua Normal file
View File

@ -0,0 +1,16 @@
<h1>Topics</h1>
<% if #topic_list == 0 then %>
<p>There are no topics.</p>
<% else %>
<ul>
<% for i, v in ipairs(topic_list) do %>
<li>
<a href=<%= url_for("topic", {slug = v.slug}) %>><%= v.name %></a> - <%= v.description %>
</li>
<% end %>
<% end %>
</ul>
<% if user:is_admin() then %>
<a href="<%= url_for("topic_create") %>">Create new topic</a>
<% end %>

12
views/user/login.etlua Normal file
View File

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

18
views/user/settings.etlua Normal file
View File

@ -0,0 +1,18 @@
<h1>User settings</h1>
<% if flash_msg then %>
<h2><%= flash_msg %></h2>
<% end %>
<form method="post" action="<%= url_for("user_set_avatar", {username = user.username}) %>" enctype="multipart/form-data">
<img src="<%= avatar_url(user) %>"><br>
<input type="file" name="avatar" accept="image/*"><br>
<input type="submit" value="Update avatar">
<% if not user:is_default_avatar() then %>
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = user.username}) %>">
<% end %>
<br>
</form>
<form method="post" action="">
<label for="status">Status</label>
<input type="text" id="status" name="status" value="<%= user.status %>" maxlength="10"><br>
<input type="submit" value="Save">
</form>

15
views/user/signup.etlua Normal file
View File

@ -0,0 +1,15 @@
<h1>Sign up</h1>
<% if err then %>
<h2><%= err %></h2>
<% end %>
<form method="post" action="<%= url_for('user_signup') %>" enctype="multipart/form-data">
<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>
<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>
<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="submit" value="Sign up">
</form>
<p>After you sign up, an administrator will need to confirm your account before you will be allowed to post.</p>

21
views/user/user.etlua Normal file
View File

@ -0,0 +1,21 @@
<% if just_logged_in then %>
<h1>Logged in successfully.</h1>
<% end %>
<img src="<%= avatar_url(user) %>">
<h1><%= user.username %></h1>
<% if user:is_guest() and user_is_me then %>
<h2>You are a guest. An administrator needs to approve your account before you will be able to post.</h2>
<% end %>
<% if user_is_me then %>
<form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>">
<input type="submit" value="Log out">
</form>
<% end %>
<% if me:is_admin() and user:is_guest() then %>
<p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p>
<form method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<input type="submit" value="Confirm user">
</form>
<% elseif me:is_admin() then %>
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p>
<% end %>