Compare commits

...

16 Commits

Author SHA1 Message Date
738b4163a8 prepare post history 2025-05-23 21:29:22 +03:00
3dde2ba49a migrate before running server 2025-05-23 21:28:14 +03:00
12269dd9b3 add sorting topics view 2025-05-23 20:41:06 +03:00
800cd6a1bf alias js folder to /static/js in ngx config 2025-05-23 20:40:32 +03:00
f3aaa6d24d fix wrong url redirect on thread create 2025-05-23 16:12:09 +03:00
f071919fa8 better? cache busting 2025-05-23 16:08:26 +03:00
d70b27cda0 use infobox to signal topic locked 2025-05-23 15:28:09 +03:00
1038e8ea1e add .touched files to ignores 2025-05-23 14:13:37 +03:00
17e231ed74 revise instructions 2025-05-23 13:35:54 +03:00
7f17d4c29e finalize docker setup
now fully works via docker \o/
2025-05-23 13:14:51 +03:00
4fa80aa8c7 VERY quick fix: randomize seed when making admin account 2025-05-23 05:41:38 +03:00
2ccacf12a3 use correct check in start script 2025-05-23 04:55:39 +03:00
0d7ed52679 NOT DONE YET - allow containerization
a bunch was restructured to make it amenable to docker.

it works fine, except when writing to the db. trying to log in (thus creating a session)
will have Lapis throw "attempt to write a readonly database"
2025-05-23 04:46:10 +03:00
af20b626d5 put db and static into data/ 2025-05-22 23:20:15 +03:00
ddad153875 argon2 experiment 2025-05-22 22:30:20 +03:00
74a0ae5027 fix infobox in user view 2025-05-22 22:30:14 +03:00
28 changed files with 391 additions and 101 deletions

8
.dockerignore Normal file
View File

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

11
.gitignore vendored
View File

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

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# HOW TO:
#
# docker compose up
#
# it exposes the data/ and secrets/ volumes in app root
#
FROM openresty/openresty:alpine-fat
RUN apk add --no-cache git make gcc g++ musl-dev libffi-dev openssl-dev sqlite-dev libsodium libsodium-dev imagemagick-dev openssl
WORKDIR /app
COPY . .
RUN eval "$(luarocks --lua-version=5.1 path)"
RUN luarocks --lua-version=5.1 build --only-deps
EXPOSE 8080
RUN chmod +x /app/start.sh
ENTRYPOINT ["/app/start.sh", "production"]

View File

@ -6,34 +6,43 @@ Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording.
# installing & first time setup
1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html).
2. then, install LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
3. then, install [LuaRocks](https://luarocks.org) (prefer your package manager instead of a local install recommended by the guide)
4. add luarocks search dirs to path:
## docker
```bash
$ docker compose up
```
- opens port 8080
- exposes `data/db` and `data/avatars` as volumes for data backup and persistence
- exposes `secrets/` as a volume so that the script won't try to perform first time setup again
## manual
1. install:
- OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html)
- LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
- openssl (-dev)
- sqlite (-dev)
- libsodium (-dev)
- imagemagick (-dev)
- [LuaRocks](https://luarocks.org) (either through the guide's instructions or your package manager, whichever is newer)
2. add luarocks search dirs to path:
```bash
# in .bashrc (or other shell equivalent)
eval "$(luarocks --lua-version 5.1 path)"
```
5. clone repo
6. install the dependencies:
3. clone repo
4. install the lua dependencies:
```bash
$ luarocks --local --lua-version 5.1 build --only-deps
```
7. create a file named `secrets.lua` in the project directory.
use the `secrets.lua.example` file as reference, and generate a cryptographically secure random key, for example, with:
5. run:
```bash
$ openssl rand -hex 32
```
8. run:
```bash
$ start.sh production
$ start.sh production # or 'development' or empty string
```
the script will perform some necessary first time setup (and create a hidden file in the folder to ensure it won't do so again). it will create an administrator account and print the credentials to the console; **this will only happen once**. make sure you save them somewhere. the administrator account is the only one that can promote other users to moderator.
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database.)
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database and shows more debug information.)
this app is made with the assumption that it is being reverse-proxied. as such, you may want to change the port to something other than the default `8080`. you can do that in [`config.lua`]([./config.lua]).

View File

@ -21,6 +21,9 @@ local function inject_methods(req)
return util.ntob(v)
end
req.PermissionLevelString = constants.PermissionLevelString
req.infobox_message = function (_, s)
return util.infobox_message(s)
end
util.pop_infobox(req)
end

View File

@ -1,23 +1,46 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local util = require("util")
local models = require("models")
local Users = models.Users
app:get("user_list", "/list", function(self)
-- everything here requires a logged in moderator
app:before_filter(function(self)
self.me = util.get_logged_in_user(self)
if not self.me then
return {redirect_to = self:url_for("all_topics")}
self:write{redirect_to = self:url_for("all_topics")}
return
end
if not self.me:is_mod() then
return {redirect_to = self:url_for("all_topics")}
self:write{redirect_to = self:url_for("all_topics")}
return
end
end)
app:get("user_list", "/list", function(self)
self.users = Users:select("")
return {render = "mod.user-list"}
end)
app:get("sort_topics", "/sort-topics", function(self)
self.topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
self.page_title = "sorting topics"
return {render = "mod.sort-topics"}
end)
app:post("sort_topics", "/sort-topics", function(self)
local updates = self.params
db.query("BEGIN")
for topic_id, new_order in pairs(updates) do
db.update("topics", {sort_order = new_order}, {id = topic_id})
end
db.query("COMMIT")
return {redirect_to = self:url_for("sort_topics")}
end)
return app

View File

@ -35,7 +35,10 @@ app:post("thread_create", "/create", function(self)
end
local topic = Topics:find(self.params.topic_id)
if not topic then
return {redirect_to = self:url_for("topics")}
return {redirect_to = self:url_for("all_topics")}
end
if util.is_topic_locked(topic) and not user:is_mod() then
return {redirect_to = self:url_for("all_topics")}
end
local title = lapis_util.trim(self.params.title)
@ -54,7 +57,7 @@ app:post("thread_create", "/create", function(self)
local post = util.create_post(thread.id, user.id, post_content)
if not post then
return {redirect_to = self:url_for("topics")}
return {redirect_to = self:url_for("all_topics")}
end
return {redirect_to = self:url_for("thread", {slug = slug})}

View File

@ -5,7 +5,7 @@ local constants = require("constants")
local util = require("util")
local bcrypt = require("bcrypt")
local auth = require("lib.auth")
local rand = require("openssl.rand")
local models = require("models")
@ -14,7 +14,7 @@ local Sessions = models.Sessions
local Avatars = models.Avatars
local function authenticate_user(user, password)
return bcrypt.verify(password, user.password_hash)
return auth.verify(password, user.password_hash)
end
local function create_session_key()
@ -321,7 +321,7 @@ app:post("user_signup", "/signup", function(self)
local new_user = Users:create({
username = username,
password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS),
password_hash = auth.digest(password),
permission = constants.PermissionLevel.GUEST,
})

View File

@ -1,5 +1,5 @@
local config = require("lapis.config")
local secrets = require("secrets")
local secrets = require("secrets.secrets")
config({"development", "production"}, {
port = 8080,
@ -7,7 +7,7 @@ config({"development", "production"}, {
code_cache = "off",
num_workers = "1",
sqlite = {
database = "db.dev.sqlite"
database = "data/db/db.dev.sqlite"
},
secret = "SUPER SECRET",
session_name = "porom_session",
@ -20,7 +20,7 @@ config("production", {
},
secret = secrets.key,
sqlite = {
database = "db.prod.sqlite"
database = "data/db/db.prod.sqlite"
},
session_name = "porom_session_s"
})

View File

@ -1,4 +1,4 @@
local bcrypt = require("bcrypt")
local auth = require("lib.auth")
local models = require("models")
local constants = require("constants")
@ -23,13 +23,14 @@ local function create_admin()
return
end
math.randomseed(os.time())
local password = ""
for _ = 1, 16 do
local randi = math.random(#alphabet)
password = password .. alphabet:sub(randi, randi)
end
local hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS)
local hash = auth.digest(password)
models.Users:create({
username = username,

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -364,3 +364,16 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
grid-area: topic-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
margin: 12px 0;
border-top: 6px outset rgb(217.26, 220.38, 213.42);
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.draggable-topic.dragged {
background-color: rgb(177, 206, 204.5);
}

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
services:
porom:
build:
context: .
ports:
- "8080:8080"
volumes:
- ./data/static:/app/data/static
- ./data/db:/app/data/db
- ./secrets:/app/secrets

45
js/sort-topics.js Normal file
View File

@ -0,0 +1,45 @@
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
let selected = null;
let container = document.getElementById("topics-container")
function isBefore(el1, el2) {
let cur
if (el2.parentNode === el1.parentNode) {
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
if (cur === el2) return true
}
}
return false;
}
function dragOver(e) {
let target = e.target.closest(".draggable-topic")
if (!target || target === selected) {
return;
}
if (isBefore(selected, target)) {
container.insertBefore(selected, target)
} else {
container.insertBefore(selected, target.nextSibling)
}
}
function dragEnd() {
if (!selected) return;
selected.classList.remove("dragged")
selected = null;
for (let i = 0; i < container.childElementCount - 1; i++) {
let input = container.children[i].querySelector(".topic-input");
input.value = i + 1;
}
}
function dragStart(e) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', null)
selected = e.target
selected.classList.add("dragged")
}

16
lib/auth.lua Normal file
View File

@ -0,0 +1,16 @@
local auth = {}
local ls = require "luasodium"
function auth.digest(password)
return ls.crypto_pwhash_str(
password,
ls.crypto_pwhash_OPSLIMIT_INTERACTIVE,
ls.crypto_pwhash_MEMLIMIT_INTERACTIVE)
end
function auth.verify(password, hash)
return ls.crypto_pwhash_str_verify(hash, password)
end
return auth

View File

@ -62,5 +62,10 @@ return {
[8] = function ()
schema.add_column("topics", "sort_order", types.integer{default = 0})
db.query("UPDATE topics SET sort_order = (SELECT COUNT(*) FROM topics t2 WHERE t2.ROWID <= topics.ROWID)")
end
end,
[9] = function ()
schema.add_column("post_history", "original_markup", types.text{null = false})
schema.add_column("post_history", "markup_language", types.text{default = "babycode"})
end,
}

View File

@ -26,16 +26,20 @@ http {
}
location /static/ {
alias static/;
alias data/static/;
}
location /favicon.ico {
alias static/favicon.ico;
alias data/static/favicon.ico;
}
location /avatars {
alias static/avatars;
alias data/static/avatars;
expires 1y;
}
location /static/js/ {
alias js/;
}
}
}

View File

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

View File

@ -366,3 +366,18 @@ input[type="text"], input[type="password"], textarea, select {
grid-area: topic-locked-container;
border: 2px outset $light;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: $accent_color;
padding: 20px;
margin: 12px 0;
border-top: 6px outset $light;
border-bottom: 6px outset $dark2;
&.dragged {
background-color: $button_color;
}
}

View File

@ -1,15 +1,24 @@
#!/bin/bash
set -e
start() {
lapis migrate
lapis serve
}
first_launch() {
echo "Setting up for the first time"
touch ".first_launch.$LAPIS_ENVIRONMENT"
lua5.1 schema.lua
mkdir -p secrets
local SECRET
SECRET="$(openssl rand -hex 32)"
echo "return { key = \"${SECRET}\",}" > secrets/secrets.lua
touch "secrets/.touched.$LAPIS_ENVIRONMENT"
mkdir -p data/db
luajit schema.lua
chmod -R a+rw data
lapis migrate
lua5.1 create_default_accounts.lua
luajit create_default_accounts.lua
}
if [[ $# -ne 1 ]]; then
@ -21,7 +30,7 @@ fi
echo "Starting in $LAPIS_ENVIRONMENT"
if ! [ -f ".first_launch.$LAPIS_ENVIRONMENT" ]; then
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
first_launch
fi

176
util.lua
View File

@ -3,6 +3,7 @@ local magick = require("magick")
local db = require("lapis.db")
local html_escape = require("lapis.html").escape
local constants = require("constants")
local string_trim = require("lapis.util").trim
local Avatars = require("models").Avatars
local Users = require("models").Users
@ -33,10 +34,131 @@ util.TransientUser = {
username = "Deleted User",
}
-- PURE API
function util.get_user_avatar_url(req, user)
return Avatars:find(user.avatar_id).file_path
end
---split a string
---@param s string subject
---@param delimiter string? string to split by, can be empty to split by character
---@param max_matches integer? the maximum number of returned elements
---@param trim boolean? whether to trim whitespace off matches
---@param allow_empty boolean? should empty matches be in the resulting table
---@return string[]
function util.s_split(s, delimiter, max_matches, trim, allow_empty)
local result = {}
if s == "" then
return result
end
trim = trim == nil and true or trim
local tr = function(subj)
if trim then return string_trim(subj) else return subj end
end
max_matches = max_matches or -1
allow_empty = allow_empty == nil and true or allow_empty
if delimiter == "" then
for i=1, #s do
local c = s:sub(i, 1)
if allow_empty or c ~= "" then
table.insert(result, c)
if max_matches > 0 and #result == max_matches then
break
end
end
end
return result
end
local current_pos = 1
local delim_len = #delimiter
while true do
if max_matches > 0 and #result >= max_matches then
break
end
---@diagnostic disable-next-line: param-type-mismatch
local start_pos, end_pos = s:find(delimiter, current_pos, true)
if not start_pos then
break
end
local substr = s:sub(current_pos, start_pos - 1)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
current_pos = end_pos + 1
end
local substr = s:sub(current_pos)
if allow_empty or substr ~= "" then
table.insert(result, tr(substr))
end
return result
end
function util.split_sentences(sentences, max_sentences)
return util.s_split(sentences, ".", max_sentences or 2, true, false)
end
function util.infobox_message(msg)
local sentences = util.split_sentences(msg)
if #sentences == 1 then
return "<b>" .. sentences[1] .. ". " .. "</b>"
end
return "<span><b>" .. sentences[1] .. ". " .. "</b> " .. sentences[2] .. ".</span>"
end
function util.get_logged_in_user(req)
if req.session.session_key == nil then
return nil
end
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
if #session > 0 then
return Users:find({id = session[1].user_id})
end
return nil
end
function util.get_logged_in_user_or_transient(req)
return util.get_logged_in_user(req) or util.TransientUser
end
function util.ntob(v)
return v ~= 0
end
function util.bton(b)
return 1 and b or 0
end
function util.stob(s)
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.is_topic_locked(topic)
return util.ntob(topic.is_locked)
end
-- OTHER API
function util.validate_and_create_image(input_image, filename)
local img = magick.load_image_from_blob(input_image)
@ -92,49 +214,8 @@ function util.destroy_avatar(avatar_id)
end
end
function util.get_logged_in_user(req)
if req.session.session_key == nil then
return nil
end
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
if #session > 0 then
return Users:find({id = session[1].user_id})
end
return nil
end
function util.get_logged_in_user_or_transient(req)
return util.get_logged_in_user(req) or util.TransientUser
end
function util.ntob(v)
return v ~= 0
end
function util.bton(b)
return 1 and b or 0
end
function util.stob(s)
if s == "true" then
return true
end
if s == "false" then
return false
end
end
function util.form_bool_to_sqlite(s)
return util.bton(util.stob(s))
end
function util.is_thread_locked(thread)
return util.ntob(thread.is_locked)
end
function util.create_post(thread_id, user_id, content)
function util.create_post(thread_id, user_id, content, markup_language)
markup_language = markup_language or "babycode"
db.query("BEGIN")
local post = Posts:create({
thread_id = thread_id,
@ -142,12 +223,17 @@ function util.create_post(thread_id, user_id, content)
current_revision_id = db.NULL,
})
local bb_content = babycode.to_html(content, html_escape)
local parsed_content = ""
if markup_language == "babycode" then
parsed_content = babycode.to_html(content, html_escape)
end
local revision = PostHistory:create({
post_id = post.id,
content = bb_content,
content = parsed_content,
is_initial_revision = true,
original_markup = content,
markup_language = "babycode",
})
post:update({current_revision_id = revision.id})

View File

@ -8,7 +8,7 @@
<title>Porom</title>
<% end %>
<% math.randomseed(os.time()) %>
<link rel="stylesheet" href="<%= "/static/style.css?" .. math.random(1, 100) %>">
<link rel="stylesheet" href="<%= "/static/style.css?v=" .. math.random(1, 100) %>">
</head>
<body>
<% render("views.common.topnav") -%>

View File

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

View File

@ -0,0 +1,19 @@
<div class="darkbg settings-container">
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<h1>Change topics order</h1>
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
<form method="post" id=topics-container>
<% for _, topic in ipairs(topics) do %>
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
<div class="thread-title"><%= topic.name %></div>
<div><%= topic.description %></div>
<input type="hidden" name="<%= topic.id %>" value="<%= topic.sort_order %>" class="topic-input">
</div>
<% end %>
<input type=submit value="Save order">
</form>
</div>
<script src="/static/js/sort-topics.js"></script>

View File

@ -2,6 +2,8 @@
<% render("views.common.infobox", infobox) %>
<% end %>
<% local is_locked = ntob(topic.is_locked) %>
<nav class="darkbg">
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
<span><%= topic.description %></span>
@ -12,25 +14,27 @@
<p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %>
<p>Only logged in users can create threads. <a href="<%= url_for("user_signup") %>">Sign up</a> or <a href="<%= url_for("user_login")%>">log in</a> to create a thread.</p>
<% else %>
<p>This topic is locked.</p>
<% end %>
<% if me:is_mod() then %>
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
<form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
<input type="hidden" name="is_locked" value="<%= not ntob(topic.is_locked) %>">
<input class="warn" type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock topic" or "Lock topic" %>">
<input type="hidden" name="is_locked" value="<%= not is_locked %>">
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>">
</form>
<% end %>
</div>
</nav>
<% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This topic is locked. Only moderators can create new threads."}) %>
<% end -%>
<% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p>
<% else %>
<% for _, thread in ipairs(threads_list) do %>
<% local is_stickied = ntob(thread.is_stickied) %>
<% local is_locked = ntob(thread.is_locked) %>
<% local thread_is_locked = ntob(thread.is_locked) %>
<div class="thread">
<div class="thread-sticky-container contain-svg">
<% if is_stickied then -%>
@ -54,7 +58,7 @@
</span>
</div>
<div class="thread-locked-container contain-svg">
<% if is_locked then -%>
<% if thread_is_locked then -%>
<% render("svg-icons.lock") %>
<i>Locked</i>
<% end -%>

View File

@ -2,6 +2,7 @@
<h1 class="thread-title">All topics</h1>
<% if me:is_mod() then %>
<a class="linkbutton" href="<%= url_for("topic_create") %>">Create new topic</a>
<a class="linkbutton" href="<%= url_for("sort_topics") %>">Sort topics</a>
<% end %>
</nav>
@ -12,7 +13,7 @@
<% local is_locked = ntob(topic.is_locked) %>
<div class="topic">
<div class="topic-info-container">
<a href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
<a class="thread-title" href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
<%= topic.description %>
<% if topic.latest_thread_username then %>
<span>

View File

@ -1,5 +1,5 @@
<% if infobox then %>
<% render("views.common.infobox", pop_infobox) %>
<% render("views.common.infobox", infobox) %>
<% end %>
<div class="darkbg">
<h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>