Compare commits
12 Commits
with-docke
...
f3aaa6d24d
Author | SHA1 | Date | |
---|---|---|---|
f3aaa6d24d
|
|||
f071919fa8
|
|||
d70b27cda0
|
|||
1038e8ea1e
|
|||
17e231ed74
|
|||
7f17d4c29e
|
|||
4fa80aa8c7
|
|||
2ccacf12a3
|
|||
0d7ed52679
|
|||
af20b626d5
|
|||
ddad153875
|
|||
74a0ae5027
|
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
logs/
|
||||
nginx.conf.compiled
|
||||
.vscode/
|
||||
.local/
|
||||
data/db/*
|
||||
secrets
|
||||
secrets/.touched*
|
||||
sass
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -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
16
Dockerfile
Normal 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"]
|
39
README.md
39
README.md
@ -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]).
|
||||
|
||||
|
3
app.lua
3
app.lua
@ -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
|
||||
|
@ -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})}
|
||||
|
@ -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,
|
||||
})
|
||||
|
||||
|
@ -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"
|
||||
})
|
||||
|
@ -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,
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal 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
|
16
lib/auth.lua
Normal file
16
lib/auth.lua
Normal 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
|
@ -26,15 +26,15 @@ 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;
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ dependencies = {
|
||||
"lapis == 1.16.0",
|
||||
"lsqlite3",
|
||||
"magick",
|
||||
"bcrypt",
|
||||
"luasodium",
|
||||
"luaossl",
|
||||
}
|
||||
|
||||
|
16
start.sh
16
start.sh
@ -1,15 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
start() {
|
||||
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 +29,7 @@ fi
|
||||
|
||||
echo "Starting in $LAPIS_ENVIRONMENT"
|
||||
|
||||
if ! [ -f ".first_launch.$LAPIS_ENVIRONMENT" ]; then
|
||||
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
|
||||
first_launch
|
||||
fi
|
||||
|
||||
|
164
util.lua
164
util.lua
@ -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,48 +214,6 @@ 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)
|
||||
db.query("BEGIN")
|
||||
local post = Posts:create({
|
||||
|
@ -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") -%>
|
||||
|
@ -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>
|
||||
|
@ -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 -%>
|
||||
|
@ -12,7 +12,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>
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user