Compare commits

...

9 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
24 changed files with 322 additions and 33 deletions

5
.gitignore vendored
View File

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

View File

@ -5,9 +5,9 @@ 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.
# installing # installing & first time setup
1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html). 1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html).
2. then, install LuaJIT 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) 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: 4. add luarocks search dirs to path:
@ -19,9 +19,27 @@ Please read the [full terms](./LICENSE.md) for proper wording.
6. install the dependencies: 6. install the dependencies:
```bash ```bash
luarocks --local --lua-version 5.1 build --only-deps $ luarocks --local --lua-version 5.1 build --only-deps
``` ```
7. rest is TBD until a start script is provided 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 # 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) 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)

View File

@ -31,6 +31,7 @@ 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(self) app:get("/", function(self)
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("all_topics")}

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

@ -22,7 +22,27 @@ 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([[
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) self.me = util.get_logged_in_user_or_transient(self)
return {render = "topics.topics"} return {render = "topics.topics"}
end) end)
@ -50,10 +70,12 @@ 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.") util.inject_infobox(self, "Topic created.")

View File

@ -150,9 +150,11 @@ app:post("user_clear_avatar", "/:username/clear_avatar", function(self)
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
local old_avatar_id = target_user.avatar_id
target_user:update({ target_user:update({
avatar_id = db.NULL, avatar_id = 1,
}) })
util.destroy_avatar(old_avatar_id)
util.inject_infobox(self, "Avatar cleared.") 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)

View File

@ -2,6 +2,7 @@ local config = require("lapis.config")
local secrets = require("secrets") local secrets = require("secrets")
config({"development", "production"}, { config({"development", "production"}, {
port = 8080,
server = "nginx", server = "nginx",
code_cache = "off", code_cache = "off",
num_workers = "1", num_workers = "1",
@ -21,4 +22,5 @@ config("production", {
sqlite = { sqlite = {
database = "db.prod.sqlite" database = "db.prod.sqlite"
}, },
session_name = "porom_session_s"
}) })

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)
@ -44,5 +55,6 @@ local function create_deleted_user()
}) })
end end
create_default_avatar()
create_admin() create_admin()
create_deleted_user() 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

@ -52,4 +52,15 @@ return {
[6] = function () [6] = function ()
schema.drop_column("post_history", "user_id") schema.drop_column("post_history", "user_id")
end, 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

@ -25,7 +25,7 @@ function Users_mt:is_logged_in_guest()
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 end
function Users_mt:is_logged_in() function Users_mt:is_logged_in()

View File

@ -35,6 +35,10 @@ $button_color: color.adjust($accent_color, $hue: 90);
&:active { &:active {
background-color: color.scale($color, $lightness: -10%, $saturation: -70%); background-color: color.scale($color, $lightness: -10%, $saturation: -70%);
} }
&:disabled {
background-color: color.scale($color, $lightness: 30%, $saturation: -90%);
}
} }
@mixin navbar($color) { @mixin navbar($color) {
@ -338,3 +342,27 @@ input[type="text"], input[type="password"], textarea, select {
text-overflow: ellipsis; text-overflow: ellipsis;
display: inline; 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;
}

View File

@ -1,3 +1,3 @@
return { return {
key = PROD_SECRET_KEY_HERE, 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

View File

@ -164,6 +164,9 @@ button:hover, input[type=submit]:hover, .linkbutton:hover {
button:active, input[type=submit]:active, .linkbutton:active { button:active, input[type=submit]:active, .linkbutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323); 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 { button.critical, input[type=submit].critical, .linkbutton.critical {
color: white; color: white;
background-color: red; background-color: red;
@ -174,6 +177,9 @@ button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:h
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active { button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
background-color: rgb(149.175, 80.325, 80.325); 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 { button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d; background-color: #fbfb8d;
} }
@ -183,6 +189,9 @@ button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active { button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
background-color: rgb(198.3813559322, 198.3813559322, 154.4186440678); 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 { input[type=file]::file-selector-button {
background-color: rgb(177, 206, 204.5); background-color: rgb(177, 206, 204.5);
@ -194,6 +203,9 @@ input[type=file]::file-selector-button:hover {
input[type=file]::file-selector-button:active { input[type=file]::file-selector-button:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323); 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 { p {
margin: 15px 0; margin: 15px 0;
@ -213,6 +225,9 @@ p {
.pagebutton:active { .pagebutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323); background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
} }
.pagebutton:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
.currentpage { .currentpage {
border: none; border: none;
@ -326,3 +341,26 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
text-overflow: ellipsis; text-overflow: ellipsis;
display: inline; 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);
}

View File

@ -34,9 +34,6 @@ util.TransientUser = {
} }
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
@ -72,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
@ -149,9 +169,7 @@ function util.transfer_and_delete_user(user)
end end
function util.pop_infobox(req) function util.pop_infobox(req)
print("1")
if not req.session.infobox then return end if not req.session.infobox then return end
print("2")
req.infobox = req.session.infobox req.infobox = req.session.infobox
req.session.infobox = nil req.session.infobox = nil
end end

View File

@ -7,6 +7,10 @@
<span> <span>
<% if me and me:is_logged_in() then -%> <% if me and me:is_logged_in() then -%>
Welcome, <a href="<%= url_for("user", {username = me.username}) %>"><%= me.username %></a> 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 -%> <% else -%>
Welcome, guest. Please <a href="<%= url_for("user_signup") %>">sign up</a> or <a href="<%= url_for("user_login") %>">log in</a> Welcome, guest. Please <a href="<%= url_for("user_signup") %>">sign up</a> or <a href="<%= url_for("user_login") %>">log in</a>
<% end -%> <% end -%>

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,7 +1,7 @@
<div class="post" id="post-<%= post.id %>"> <div class="post" id="post-<%= post.id %>">
<div class="usercard"> <div class="usercard">
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;"> <a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
<img src="<%= post.avatar_path or "/avatars/default.webp" %>" class="avatar"> <img src="<%= post.avatar_path %>" class="avatar">
</a> </a>
<a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a> <a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a>
<% if post.status ~= "" then %> <% if post.status ~= "" then %>

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,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 me:is_mod() then %>
<a href="<%= url_for("topic_create") %>">Create new topic</a>
<% end %>

View File

@ -1,3 +1,4 @@
<% local disable_avatar = me:is_logged_in_guest() %>
<div class="darkbg settings-container"> <div class="darkbg settings-container">
<h1>User settings</h1> <h1>User settings</h1>
<% if infobox then %> <% if infobox then %>
@ -7,7 +8,7 @@
<img src="<%= avatar_url(me) %>"> <img src="<%= avatar_url(me) %>">
<input id="file" type="file" name="avatar" accept="image/*" required> <input id="file" type="file" name="avatar" accept="image/*" required>
<div> <div>
<input type="submit" value="Update avatar"> <input type="submit" value="Update avatar" <%= disable_avatar and "disabled=disabled" %>>
<% if not me:is_default_avatar() then %> <% if not me:is_default_avatar() then %>
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate> <input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
<% end %> <% end %>