add deleting, promoting/demoting, guesting (soft banning) users

This commit is contained in:
Lera Elvoé 2025-05-19 18:34:21 +03:00
parent 349f4d38ef
commit a5a7175365
Signed by: yagich
SSH Key Fingerprint: SHA256:6xjGb6uA7lAVcULa7byPEN//rQ0wPoG+UzYVMfZnbvc
14 changed files with 234 additions and 37 deletions

View File

@ -1,5 +1,10 @@
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")
@ -11,6 +16,7 @@ local function inject_methods(req)
req.ntob = function(_, v) req.ntob = function(_, v)
return util.ntob(v) return util.ntob(v)
end end
req.PermissionLevelString = constants.PermissionLevelString
end end
app:before_filter(inject_methods) app:before_filter(inject_methods)

View File

@ -109,7 +109,7 @@ 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

View File

@ -27,7 +27,7 @@ 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
@ -36,7 +36,7 @@ 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
@ -72,7 +72,7 @@ app:get("topic", "/:slug", function(self)
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
@ -81,7 +81,7 @@ 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({
@ -96,7 +96,7 @@ 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

@ -79,13 +79,59 @@ app:get("user", "/:username", function(self)
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
return {render = "user.user"} return {render = "user.user"}
end) end)
app:post("user_delete", "/:username/delete", 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 not me:is_mod() then
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
self.session.flash = {error = "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)
self.session.flash = {error = "Your account has been added to the deletion queue."}
return {redirect_to = self:url_for("user_signup")}
else
if target_user.permission >= me.permission then
self.session.flash = {error = "You can not delete another moderator."}
return {redirect_to = self:url_for("user", {username = me.username})}
end
end
end)
app:get("user_delete_confirm", "/:username/delete_confirm", 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
self.err = self.session.flash.error
self.session.flash = {}
end
self.user = target_user
return {render = "user.delete_confirm"}
end)
app:post("user_clear_avatar", "/:username/clear_avatar", function(self) app:post("user_clear_avatar", "/:username/clear_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
@ -216,6 +262,10 @@ app:post("user_login", "/login", function(self)
self.session.flash = {error = "Invalid username or password"} self.session.flash = {error = "Invalid username or password"}
return {redirect_to = self:url_for("user_login")} return {redirect_to = self:url_for("user_login")}
end end
if user.permission == constants.PermissionLevel.SYSTEM 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 if not authenticate_user(user, password) then
self.session.flash = {error = "Invalid username or password"} self.session.flash = {error = "Invalid username or password"}
return {redirect_to = self:url_for("user_login")} return {redirect_to = self:url_for("user_login")}
@ -300,7 +350,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 +365,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)
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 return app

View File

@ -3,7 +3,17 @@ 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.BCRYPT_ROUNDS = 10 Constants.BCRYPT_ROUNDS = 10

View File

@ -29,4 +29,20 @@ 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_admin()
create_deleted_user()

View File

@ -12,6 +12,14 @@ 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

View File

@ -14,6 +14,9 @@ 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,
@ -116,7 +119,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 +129,16 @@ 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
return util

View File

@ -15,14 +15,14 @@
<% if thread_create_error == ThreadCreateError.OK then %> <% if thread_create_error == ThreadCreateError.OK then %>
<a href=<%= url_for("thread_create", nil, {topic_id = topic.id}) %>>New thread</a> <a href=<%= url_for("thread_create", nil, {topic_id = topic.id}) %>>New thread</a>
<% elseif thread_create_error == ThreadCreateError.GUEST then %> <% elseif thread_create_error == ThreadCreateError.GUEST then %>
<p>Your account is still pending confirmation by an administrator. You are not able to create a new thread or post at this time.</p> <p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %> <% 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> <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> <p>This topic is locked.</p>
<% end %> <% end %>
<% if user:is_admin() then %> <% if user:is_mod() then %>
<br> <br>
<a href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a> <a href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
<form method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>"> <form method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">

View File

@ -11,6 +11,6 @@
<% end %> <% end %>
<% end %> <% end %>
</ul> </ul>
<% if user:is_admin() then %> <% if user:is_mod() then %>
<a href="<%= url_for("topic_create") %>">Create new topic</a> <a href="<%= url_for("topic_create") %>">Create new topic</a>
<% end %> <% end %>

View File

@ -0,0 +1,12 @@
<h1>Are you sure you want to delete your account, <%= user.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 err then %>
<h2><%= err %></h2>
<% end %>
<form method="post" action="<%= url_for("user_delete", {username = user.username}) %>">
<input type="password" name="password" id="password" autocomplete="current-password" placeholder="Password" required><br>
<input type="submit" value="Delete my account (NO UNDO)">
</form>

View File

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

View File

@ -12,4 +12,4 @@
<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>

View File

@ -1,22 +1,41 @@
<% if just_logged_in then %> <% if just_logged_in then %>
<h1>Logged in successfully.</h1> <h1>Logged in successfully.</h1>
<% end %> <% end %>
<img src="<%= avatar_url(user) %>"> <img src="<%= avatar_url(user) %>">
<h1><%= user.username %></h1> <h1><%= user.username %></h1>
<h2><%= PermissionLevelString[user.permission] %></h2>
<% 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. An Moderator needs to approve your account before you will be able to post.</h2>
<% end %> <% end %>
<% if user_is_me then %> <% if user_is_me then %>
<a href="<%= url_for("user_settings", {username = user.username}) %>">Settings</a> <a href="<%= url_for("user_settings", {username = user.username}) %>">Settings</a>
<form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>"> <form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>">
<input type="submit" value="Log out"> <input type="submit" value="Log out">
</form> </form>
<% end %> <% 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> <% if me:is_mod() and not user:is_system() then %>
<form method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>"> <h1>Moderator controls</h2>
<input type="submit" value="Confirm user"> <% if user:is_guest() then %>
</form> <p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p>
<% elseif me:is_admin() then %> <form method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p> <input type="submit" value="Confirm user">
</form>
<% else %> <% --[[ user is not guest ]] %>
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p>
<% if user.id ~= me.id and user.permission < me.permission then %>
<form method="post" action="<%= url_for("guest_user", {user_id = user.id}) %>">
<input type="submit" value="Demote user to guest (soft ban)">
</form>
<% end %>
<% if me:is_admin() and not user:is_mod() then %>
<form method="post" action="<%= url_for("mod_user", {user_id = user.id}) %>">
<input type="submit" value="Promote user to moderator">
</form>
<% elseif me:is_admin() then %>
<form method="post" action="<%= url_for("demod_user", {user_id = user.id}) %>">
<input type="submit" value="Demote user to regular user">
</form>
<% end %>
<% end %>
<% end %> <% end %>