Compare commits

..

1 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
44 changed files with 186 additions and 817 deletions

View File

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

11
.gitignore vendored
View File

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

View File

@ -1,16 +0,0 @@
# 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,43 +6,34 @@ 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 & first time setup # installing & first time setup
## docker 1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html).
```bash 2. then, install LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
$ docker compose up 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:
- 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 ```bash
# in .bashrc (or other shell equivalent) # in .bashrc (or other shell equivalent)
eval "$(luarocks --lua-version 5.1 path)" eval "$(luarocks --lua-version 5.1 path)"
``` ```
3. clone repo 5. clone repo
4. install the lua 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
``` ```
5. run: 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 ```bash
$ start.sh production # or 'development' or empty string $ 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. 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 and shows more debug information.) (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]). 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,9 +21,6 @@ local function inject_methods(req)
return util.ntob(v) return util.ntob(v)
end end
req.PermissionLevelString = constants.PermissionLevelString req.PermissionLevelString = constants.PermissionLevelString
req.infobox_message = function (_, s)
return util.infobox_message(s)
end
util.pop_infobox(req) util.pop_infobox(req)
end end
@ -35,7 +32,6 @@ 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:include("apps.mod", {path = "/mod"})
app:include("apps.post", {path = "/post"})
app:get("/", function(self) app:get("/", function(self)
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("all_topics")}

View File

@ -1,46 +1,23 @@
local app = require("lapis").Application() local app = require("lapis").Application()
local db = require("lapis.db")
local util = require("util") local util = require("util")
local models = require("models") local models = require("models")
local Users = models.Users local Users = models.Users
-- everything here requires a logged in moderator app:get("user_list", "/list", function(self)
app:before_filter(function(self)
self.me = util.get_logged_in_user(self) self.me = util.get_logged_in_user(self)
if not self.me then if not self.me then
self:write{redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("all_topics")}
return
end end
if not self.me:is_mod() then if not self.me:is_mod() then
self:write{redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("all_topics")}
return
end end
end)
app:get("user_list", "/list", function(self)
self.users = Users:select("") self.users = Users:select("")
return {render = "mod.user-list"} return {render = "mod.user-list"}
end) 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 return app

View File

@ -1,75 +0,0 @@
local app = require("lapis").Application()
local db = require("lapis.db")
local constants = require("constants")
local util = require("util")
local models = require("models")
local Posts = models.Posts
local Threads = models.Threads
app:get("single_post", "/:post_id", function(self)
local query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
local p = db.query(query, self.params.post_id)
if p then
self.post = p[1]
self.thread = Threads:find({id = self.post.thread_id})
self.page_title = self.post.username .. "'s post in " .. self.thread.title
end
return {render = "post.single-post"}
end)
app:get("edit_post", "/:post_id/edit", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for"all_topics"}
end
local editing_query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
local p = db.query(editing_query, self.params.post_id)
if not p then
return {redirect_to = self:url_for"all_topics"}
end
if p[1].user_id ~= user.id then
return {redirect_to = self:url_for"all_topics"}
end
self.me = user
self.editing_post = p[1]
self.thread = Threads:find({id = self.editing_post.thread_id})
local thread_predicate = constants.FULL_POSTS_QUERY .. "WHERE posts.thread_id = ?\n"
local context_prev_query = thread_predicate .. "AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2"
local context_next_query = thread_predicate .. "AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2"
self.prev_context = db.query(context_prev_query, self.thread.id, self.editing_post.created_at)
self.next_context = db.query(context_next_query, self.thread.id, self.editing_post.created_at)
return {render = "post.edit-post"}
end)
app:post("edit_post", "/:post_id/edit", function(self)
local user = util.get_logged_in_user(self)
if not user then
return {redirect_to = self:url_for("all_topics")}
end
local post = Posts:find({id = self.params.post_id})
if not post then
return {redirect_to = self:url_for("all_topics")}
end
if post.user_id ~= user.id then
return {redirect_to = self:url_for("all_topics")}
end
util.update_post(post, self.params.new_content)
local thread = Threads:find({id = post.thread_id})
local link = self:url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id
return {redirect_to = link}
end)
return app

View File

@ -1,6 +1,5 @@
local app = require("lapis").Application() local app = require("lapis").Application()
local lapis_util = require("lapis.util") local lapis_util = require("lapis.util")
local constants = require("constants")
local db = require("lapis.db") local db = require("lapis.db")
local util = require("util") local util = require("util")
@ -36,10 +35,7 @@ app:post("thread_create", "/create", function(self)
end end
local topic = Topics:find(self.params.topic_id) local topic = Topics:find(self.params.topic_id)
if not topic then if not topic then
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("topics")}
end
if util.is_topic_locked(topic) and not user:is_mod() then
return {redirect_to = self:url_for("all_topics")}
end end
local title = lapis_util.trim(self.params.title) local title = lapis_util.trim(self.params.title)
@ -58,7 +54,7 @@ app:post("thread_create", "/create", function(self)
local post = util.create_post(thread.id, user.id, post_content) local post = util.create_post(thread.id, user.id, post_content)
if not post then if not post then
return {redirect_to = self:url_for("all_topics")} return {redirect_to = self:url_for("topics")}
end end
return {redirect_to = self:url_for("thread", {slug = slug})} return {redirect_to = self:url_for("thread", {slug = slug})}
@ -90,9 +86,23 @@ app:get("thread", "/:slug", function(self)
end end
-- self.page = math.max(1, math.min(self.page, self.pages)) -- self.page = math.max(1, math.min(self.page, self.pages))
local query = (constants.FULL_POSTS_QUERY .. local posts = db.query([[
"WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?") SELECT
local posts = db.query(query, thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE) posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, 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 = ?
ORDER BY
posts.created_at ASC
LIMIT ? OFFSET ?
]], thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
self.topic = Topics:find(thread.topic_id) self.topic = Topics:find(thread.topic_id)
self.me = util.get_logged_in_user_or_transient(self) self.me = util.get_logged_in_user_or_transient(self)
self.posts = posts self.posts = posts

View File

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

View File

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

View File

@ -8,19 +8,6 @@ Constants.PermissionLevel = {
ADMIN = 4, ADMIN = 4,
} }
Constants.FULL_POSTS_QUERY = [[
SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup
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
]]
Constants.PermissionLevelString = { Constants.PermissionLevelString = {
[Constants.PermissionLevel.GUEST] = "Guest", [Constants.PermissionLevel.GUEST] = "Guest",
[Constants.PermissionLevel.USER] = "User", [Constants.PermissionLevel.USER] = "User",

View File

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

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"

View File

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

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

@ -1,7 +0,0 @@
for (let button of document.querySelectorAll(".copy-code")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.value)
button.textContent = "Copied!"
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
})
}

View File

@ -1,54 +0,0 @@
{
let ta = document.getElementById("post_content");
const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike");
const buttonCode = document.getElementById("post-editor-code");
function insertTag(tagStart, newline = false) {
const tagEnd = tagStart;
const tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
const hasSelection = ta.selectionStart !== ta.selectionEnd;
const text = ta.value;
if (hasSelection) {
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
const selectionLength = realEnd - realStart;
const strStart = text.slice(0, realStart);
const strEnd = text.substring(realEnd);
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
const reconst = `${strStart}${frag}${strEnd}`;
ta.value = reconst;
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
ta.focus()
} else {
const cursor = ta.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substr(cursor);
const newCursor = strStart.length + tagInsertStart.length;
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
ta.value = reconst;
ta.setSelectionRange(newCursor, newCursor);
ta.focus()
}
}
buttonBold.addEventListener("click", (e) => {
e.preventDefault();
insertTag("b")
})
buttonItalics.addEventListener("click", (e) => {
e.preventDefault();
insertTag("i")
})
buttonStrike.addEventListener("click", (e) => {
e.preventDefault();
insertTag("s")
})
buttonCode.addEventListener("click", (e) => {
e.preventDefault();
insertTag("code", true)
})
}

View File

@ -1,45 +0,0 @@
// 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")
}

View File

@ -1,10 +0,0 @@
{
const ta = document.getElementById("post_content");
for (let button of document.querySelectorAll(".reply-button")) {
button.addEventListener("click", (e) => {
ta.value += button.value;
ta.scrollIntoView()
})
}
}

View File

@ -1,16 +0,0 @@
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

@ -44,9 +44,7 @@ function babycode.to_html(s, escape_html)
-- replace code block placeholders back with their original contents -- replace code block placeholders back with their original contents
text = text:gsub("\1CODE:(%d+)\1", function(n) text = text:gsub("\1CODE:(%d+)\1", function(n)
local code = code_blocks[tonumber(n)] return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>"
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(code)
return "<pre><span class=\"copy-code-container\">" .. button .. "</span><code>"..code.."</code></pre>"
end) end)
return text return text

View File

@ -62,10 +62,5 @@ return {
[8] = function () [8] = function ()
schema.add_column("topics", "sort_order", types.integer{default = 0}) 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)") 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,20 +26,16 @@ http {
} }
location /static/ { location /static/ {
alias data/static/; alias static/;
} }
location /favicon.ico { location /favicon.ico {
alias data/static/favicon.ico; alias static/favicon.ico;
} }
location /avatars { location /avatars {
alias data/static/avatars; alias static/avatars;
expires 1y; expires 1y;
} }
location /static/js/ {
alias js/;
}
} }
} }

View File

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

View File

@ -6,7 +6,6 @@ $accent_color: #c1ceb1;
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%); $dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%); $dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%); $light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%); $lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
@ -141,45 +140,7 @@ body {
.post-content { .post-content {
grid-area: post-content; grid-area: post-content;
padding: 20px; padding: 5px 20px;
margin-right: 25%;
}
pre code {
display: block;
background-color: $verydark;
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid $lighter;
padding: 20px;
}
.copy-code-container {
position: sticky;
// width: 100%;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: sans-serif;
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: $accent_color;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
&::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
}
.copy-code {
margin-right: 10px;
} }
.user-posts { .user-posts {
@ -380,7 +341,6 @@ input[type="text"], input[type="password"], textarea, select {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: inline; display: inline;
margin-right: 25%;
} }
.topic { .topic {
@ -406,45 +366,3 @@ input[type="text"], input[type="password"], textarea, select {
grid-area: topic-locked-container; grid-area: topic-locked-container;
border: 2px outset $light; 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;
}
}
.editing {
background-color: $light;
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
}
ul {
margin: 10px 0 10px 30px;
padding: 0;
}

View File

@ -1,24 +1,15 @@
#!/bin/bash #!/bin/bash
set -e
start() { start() {
lapis migrate
lapis serve lapis serve
} }
first_launch() { first_launch() {
echo "Setting up for the first time" echo "Setting up for the first time"
mkdir -p secrets touch ".first_launch.$LAPIS_ENVIRONMENT"
local SECRET lua5.1 schema.lua
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 lapis migrate
luajit create_default_accounts.lua lua5.1 create_default_accounts.lua
} }
if [[ $# -ne 1 ]]; then if [[ $# -ne 1 ]]; then
@ -30,7 +21,7 @@ fi
echo "Starting in $LAPIS_ENVIRONMENT" echo "Starting in $LAPIS_ENVIRONMENT"
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then if ! [ -f ".first_launch.$LAPIS_ENVIRONMENT" ]; then
first_launch first_launch
fi fi

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -105,43 +105,7 @@ body {
.post-content { .post-content {
grid-area: post-content; grid-area: post-content;
padding: 20px; padding: 5px 20px;
margin-right: 25%;
}
pre code {
display: block;
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid rgb(229.84, 231.92, 227.28);
padding: 20px;
}
.copy-code-container {
position: sticky;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: sans-serif;
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: #c1ceb1;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
}
.copy-code-container::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
.copy-code {
margin-right: 10px;
} }
.user-posts { .user-posts {
@ -376,7 +340,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: inline; display: inline;
margin-right: 25%;
} }
.topic { .topic {
@ -401,42 +364,3 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
grid-area: topic-locked-container; grid-area: topic-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42); 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);
}
.editing {
background-color: rgb(217.26, 220.38, 213.42);
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
}
ul {
margin: 10px 0 10px 30px;
padding: 0;
}

197
util.lua
View File

@ -3,7 +3,6 @@ local magick = require("magick")
local db = require("lapis.db") local db = require("lapis.db")
local html_escape = require("lapis.html").escape local html_escape = require("lapis.html").escape
local constants = require("constants") local constants = require("constants")
local string_trim = require("lapis.util").trim
local Avatars = require("models").Avatars local Avatars = require("models").Avatars
local Users = require("models").Users local Users = require("models").Users
@ -34,131 +33,10 @@ util.TransientUser = {
username = "Deleted User", username = "Deleted User",
} }
-- PURE API
function util.get_user_avatar_url(req, user) function util.get_user_avatar_url(req, user)
return Avatars:find(user.avatar_id).file_path return Avatars:find(user.avatar_id).file_path
end 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) function util.validate_and_create_image(input_image, filename)
local img = magick.load_image_from_blob(input_image) local img = magick.load_image_from_blob(input_image)
@ -214,8 +92,49 @@ function util.destroy_avatar(avatar_id)
end end
end end
function util.create_post(thread_id, user_id, content, markup_language) function util.get_logged_in_user(req)
markup_language = markup_language or "babycode" 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") db.query("BEGIN")
local post = Posts:create({ local post = Posts:create({
thread_id = thread_id, thread_id = thread_id,
@ -223,17 +142,12 @@ function util.create_post(thread_id, user_id, content, markup_language)
current_revision_id = db.NULL, current_revision_id = db.NULL,
}) })
local parsed_content = "" local bb_content = babycode.to_html(content, html_escape)
if markup_language == "babycode" then
parsed_content = babycode.to_html(content, html_escape)
end
local revision = PostHistory:create({ local revision = PostHistory:create({
post_id = post.id, post_id = post.id,
content = parsed_content, content = bb_content,
is_initial_revision = true, is_initial_revision = true,
original_markup = content,
markup_language = "babycode",
}) })
post:update({current_revision_id = revision.id}) post:update({current_revision_id = revision.id})
@ -242,27 +156,6 @@ function util.create_post(thread_id, user_id, content, markup_language)
return post return post
end end
function util.update_post(post, new_content, markup_language)
markup_language = markup_language or "babycode"
db.query("BEGIN")
local parsed_content = ""
if markup_language == "babycode" then
parsed_content = babycode.to_html(new_content, html_escape)
end
local revision = PostHistory:create({
post_id = post.id,
content = parsed_content,
is_initial_revision = false,
original_markup = new_content,
markup_language = markup_language
})
post:update({current_revision_id = revision.id})
db.query("COMMIT")
end
function util.transfer_and_delete_user(user) function util.transfer_and_delete_user(user)
local deleted_user = Users:find({ local deleted_user = Users:find({
username = "DeletedUser", username = "DeletedUser",

View File

@ -8,11 +8,10 @@
<title>Porom</title> <title>Porom</title>
<% end %> <% end %>
<% math.randomseed(os.time()) %> <% math.randomseed(os.time()) %>
<link rel="stylesheet" href="<%= "/static/style.css?v=" .. math.random(1, 100) %>"> <link rel="stylesheet" href="<%= "/static/style.css?" .. math.random(1, 100) %>">
</head> </head>
<body> <body>
<% render("views.common.topnav") -%> <% render("views.common.topnav") -%>
<% content_for("inner") %> <% content_for("inner") %>
<script src="/static/js/copy-code.js"></script>
</body> </body>
</html> </html>

View File

@ -1,8 +0,0 @@
<span>
<button type=button id="post-editor-bold" title="Insert Bold">B</button>
<button type=button id="post-editor-italics" title="Insert Italics">I</button>
<button type=button id="post-editor-strike" title="Insert Strikethrough">S</button>
<button type=button id="post-editor-code" title="Insert Code block">Code</button>
</span>
<textarea class="babycode-editor" name="<%= ta_name %>" id="post_content" placeholder="Post body" required><%- prefill or "" %></textarea>
<script src="/static/js/post-editor.js"></script>

View File

@ -1,16 +0,0 @@
<%
local save_button_text = "Post reply"
if cancel_url then
save_button_text = "Save"
end
%>
<form class="post-edit-form" method="post" action="<%= url or "" %>">
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
<span>
<input type=submit value="<%= save_button_text %>">
<% if cancel_url then %>
<a class="linkbutton warn" href="<%= cancel_url %>">Cancel</a>
<% end %>
</span>
<% render("views.common.bbcode_help") %>
</form>

View File

@ -1,5 +1,5 @@
<details> <details>
<summary>babycode guide</summary> <summary>Supported babycode tags</summary>
<ul> <ul>
<li>[b]<b>bold</b>[/b]</li> <li>[b]<b>bold</b>[/b]</li>
<li>[i]<i>italic</i>[/i]</li> <li>[i]<i>italic</i>[/i]</li>
@ -8,4 +8,4 @@
<li>[url]<a href="https://unlabeled-url.example.com">https://unlabeled-url.example.com</a>[/url]</li> <li>[url]<a href="https://unlabeled-url.example.com">https://unlabeled-url.example.com</a>[/url]</li>
<li>[code]<code>code block</code>[/code]</li> <li>[code]<code>code block</code>[/code]</li>
</ul> </ul>
</details> </details>

View File

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

View File

@ -1,19 +0,0 @@
<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

@ -1,16 +0,0 @@
<% for _, post in ipairs(prev_context) do %>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<% end %>
<span class="context-explain">
<span>&uarr;&uarr;&uarr;</span><i>Context</i><span>&uarr;&uarr;&uarr;</span>
</span>
<% if infobox then %>
<% render("views.common.infobox", infobox) %>
<% end %>
<% render("views.threads.post", {post = editing_post, edit = true, is_latest = false, no_reply = true}) %>
<span class="context-explain">
<span>&darr;&darr;&darr;</span><i>Context</i><span>&darr;&darr;&darr;</span>
</span>
<% for _, post in ipairs(next_context) do %>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<% end %>

View File

@ -1,9 +0,0 @@
<% if not post then %>
<% render("views.common.infobox", {kind = constants.InfoboxKind.ERROR, msg = "Post not found"}) %>
<% else %>
<div class=darkbg>
<h1 class=thread-title><%= post.username .. "'s post in " .. thread.title %></h1>
</div>
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
<a class=linkbutton href="<%= url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id %>">View in context</a>
<% end %>

View File

@ -9,8 +9,8 @@
</select><br> </select><br>
<label for="title">Thread title</label> <label for="title">Thread title</label>
<input type="text" id="title" name="title" placeholder="Required" required> <input type="text" id="title" name="title" placeholder="Required" required>
<label for="initial_post">Post body</label><br> <label for="initial_post">Post body</label>
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %> <textarea id="initial_post" name="initial_post" placeholder="Required" rows=5 required></textarea>
<% render "views.common.bbcode_help" %> <% render "views.common.bbcode_help" %>
<input type="submit" value="Create thread"> <input type="submit" value="Create thread">
</form> </form>

View File

@ -1,10 +1,4 @@
<% <div class="post" id="post-<%= post.id %>">
local pc = "post"
if edit then
pc = pc .. " editing"
end
%>
<div class="<%= pc %>" 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 %>" class="avatar"> <img src="<%= post.avatar_path %>" class="avatar">
@ -14,52 +8,19 @@
<em class="user-status"><%= post.status %></em> <em class="user-status"><%= post.status %></em>
<% end %> <% end %>
</div> </div>
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>> <div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
<div class="post-info"> <div class="post-info">
<a href="<%= url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id %>" title="Permalink"><i> <div><a href="<%= "#post-" .. post.id %>" title="Permalink"><i>
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%> <% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
Edited at <%= os.date("%c", post.edited_at) %> Edited at <%= os.date("%c", post.edited_at) %>
<% else -%> <% else -%>
Posted at <%= os.date("%c", post.created_at) %> Posted at <%= os.date("%c", post.created_at) %>
<% end -%> <% end -%>
</i></a> </i></a></div>
<span> <div><button>Reply</button></div>
<%
local show_edit = me.id == post.user_id and not me:is_guest() and not ntob(thread.is_locked) and not no_reply
if show_edit then
%>
<a class="linkbutton" href="<%= url_for("edit_post", {post_id = post.id}) %>">Edit</a>
<% end %>
<%
local show_reply = true
if ntob(thread.is_locked) and not me:is_mod() then
show_reply = false
elseif me:is_guest() then
show_reply = false
elseif edit then
show_reply = false
elseif no_reply then
show_reply = false
end
if show_reply then
local d = post.created_at < post.edited_at and post.edited_at or post.created_at
local reply_text = ("On %s, %s said:\n%s\n\n---\n\n"):format(os.date("%c", d), post.username, post.original_markup)
%>
<button value="<%= reply_text %>" class="reply-button">Reply</button>
<% end %>
</span>
</div> </div>
<div class="post-content"> <div class="post-content">
<% if not edit then %> <%- post.content %>
<%- post.content %>
<% else %>
<% render("views.common.babycode-editor", {
cancel_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id,
prefill = post.original_markup,
ta_name = "new_content"
}) %>
<% end %>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,4 @@
<% <% local is_locked = ntob(thread.is_locked) %>
local is_locked = ntob(thread.is_locked)
local can_post = (not is_locked and not me:is_guest()) or me:is_mod()
%>
<main> <main>
<nav class="darkbg"> <nav class="darkbg">
<h1 class="thread-title"><%= thread.title %></h1> <h1 class="thread-title"><%= thread.title %></h1>
@ -19,8 +16,10 @@
<% if is_locked then -%> <% if is_locked then -%>
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %> <% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
<% end -%> <% end -%>
<% if can_post then %> <% if not me:is_guest() and not is_locked then %>
<h1>Respond to "<%= thread.title %>"</h1> <h1>Respond to "<%= thread.title %>"</h1>
<% render("views.common.babycode-editor", {ta_name="post_content"}) %> <form method="post">
<script src="/static/js/thread.js"></script> <textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br>
<input type="submit" value="Post reply">
</form>
<% end %> <% end %>

View File

@ -2,8 +2,6 @@
<% render("views.common.infobox", infobox) %> <% render("views.common.infobox", infobox) %>
<% end %> <% end %>
<% local is_locked = ntob(topic.is_locked) %>
<nav class="darkbg"> <nav class="darkbg">
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1> <h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
<span><%= topic.description %></span> <span><%= topic.description %></span>
@ -14,27 +12,25 @@
<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> <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 %>
<p>This topic is locked.</p>
<% end %> <% end %>
<% if me:is_mod() then %> <% if me:is_mod() then %>
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a> <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}) %>"> <form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
<input type="hidden" name="is_locked" value="<%= not is_locked %>"> <input type="hidden" name="is_locked" value="<%= not ntob(topic.is_locked) %>">
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>"> <input class="warn" type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock topic" or "Lock topic" %>">
</form> </form>
<% end %> <% end %>
</div> </div>
</nav> </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 %> <% if #threads_list == 0 then %>
<p>There are no threads in this topic.</p> <p>There are no threads in this topic.</p>
<% else %> <% else %>
<% for _, thread in ipairs(threads_list) do %> <% for _, thread in ipairs(threads_list) do %>
<% local is_stickied = ntob(thread.is_stickied) %> <% local is_stickied = ntob(thread.is_stickied) %>
<% local thread_is_locked = ntob(thread.is_locked) %> <% local is_locked = ntob(thread.is_locked) %>
<div class="thread"> <div class="thread">
<div class="thread-sticky-container contain-svg"> <div class="thread-sticky-container contain-svg">
<% if is_stickied then -%> <% if is_stickied then -%>
@ -58,7 +54,7 @@
</span> </span>
</div> </div>
<div class="thread-locked-container contain-svg"> <div class="thread-locked-container contain-svg">
<% if thread_is_locked then -%> <% if is_locked then -%>
<% render("svg-icons.lock") %> <% render("svg-icons.lock") %>
<i>Locked</i> <i>Locked</i>
<% end -%> <% end -%>

View File

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

View File

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