315 lines
12 KiB
Lua
315 lines
12 KiB
Lua
-- db.lua — database layer using Fullmoon's makeStorage (lsqlite3 wrapper)
|
|
-- The storage handle is opened once via M.init() and reused across all requests.
|
|
-- makeStorage handles WAL pragmas and fork-safety automatically.
|
|
--
|
|
-- NOTE: FTS5 full-text search is intentionally omitted.
|
|
-- The redbean.com binary shipped does not include SQLite's FTS5 extension.
|
|
-- When a custom APE fatbin with FTS5 is available, replace M.search() with
|
|
-- the FTS5 implementation and add the virtual table + triggers back to SCHEMA.
|
|
--
|
|
-- Tags are stored as a comma-separated string in the `tags` column.
|
|
-- Splitting, filtering, and counting are done in Lua at query time.
|
|
|
|
local fm = require "fm"
|
|
|
|
local M = {}
|
|
|
|
-- ── Schema ────────────────────────────────────────────────────────────────
|
|
-- Pragmas must come first; makeStorage extracts them for re-application
|
|
-- after a fork. All DDL uses IF NOT EXISTS so init() is idempotent.
|
|
local SCHEMA = [[
|
|
PRAGMA journal_mode=WAL;
|
|
PRAGMA synchronous=NORMAL;
|
|
PRAGMA foreign_keys=ON;
|
|
|
|
CREATE TABLE IF NOT EXISTS content (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL CHECK(kind IN ('blog','wiki','docs')),
|
|
slug TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
tags TEXT NOT NULL DEFAULT '',
|
|
published INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
UNIQUE(kind, slug)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
]]
|
|
|
|
-- ── Module-level storage handle ───────────────────────────────────────────
|
|
local store -- set by M.init()
|
|
|
|
-- Convert NONE sentinel → nil (single-row lookups).
|
|
local function one(row)
|
|
if row == store.NONE then return nil end
|
|
return row
|
|
end
|
|
|
|
-- Convert NONE sentinel → {} (list queries).
|
|
local function many(rows)
|
|
if rows == store.NONE then return {} end
|
|
return rows
|
|
end
|
|
|
|
-- Columns returned by general content queries.
|
|
-- file_data is intentionally excluded: blobs are only fetched by get_doc().
|
|
local CONTENT_COLS =
|
|
"id, kind, slug, title, body, summary, tags, published, created_at, updated_at, mime_type"
|
|
|
|
-- ── Initialisation ────────────────────────────────────────────────────────
|
|
|
|
--- Open blog.db and apply the schema. Safe to call multiple times.
|
|
--- path defaults to "blog.db" in the current working directory.
|
|
function M.init(path)
|
|
store = fm.makeStorage(path or "blog.db", SCHEMA)
|
|
-- Migrations: ALTER TABLE ignores duplicate-column errors via pcall.
|
|
pcall(function()
|
|
store:execute("ALTER TABLE content ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
|
|
end)
|
|
pcall(function()
|
|
store:execute("ALTER TABLE content ADD COLUMN file_data BLOB")
|
|
end)
|
|
pcall(function()
|
|
store:execute("ALTER TABLE content ADD COLUMN mime_type TEXT NOT NULL DEFAULT ''")
|
|
end)
|
|
end
|
|
|
|
-- ── Content CRUD ──────────────────────────────────────────────────────────
|
|
|
|
--- Fetch one published row by kind + slug. Returns nil if missing.
|
|
--- Pass include_drafts=true (admin preview) to also match drafts.
|
|
--- Does NOT include file_data; use get_doc() for binary blobs.
|
|
function M.get_content(kind, slug, include_drafts)
|
|
local sql = "SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND slug=?"
|
|
if not include_drafts then sql = sql .. " AND published=1" end
|
|
return one(store:fetchOne(sql, kind, slug))
|
|
end
|
|
|
|
--- All rows for a kind, newest first (drafts included).
|
|
function M.list_content(kind)
|
|
return many(store:fetchAll(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? ORDER BY created_at DESC", kind))
|
|
end
|
|
|
|
--- Published-only rows for a kind, newest first.
|
|
function M.list_published(kind)
|
|
return many(store:fetchAll(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND published=1 ORDER BY created_at DESC",
|
|
kind))
|
|
end
|
|
|
|
--- One page of published rows for a kind, newest first.
|
|
function M.list_published_page(kind, limit, offset)
|
|
return many(store:fetchAll(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content WHERE kind=? AND published=1"
|
|
.. " ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
kind, limit, offset or 0))
|
|
end
|
|
|
|
--- Count of published rows for a kind.
|
|
function M.count_published(kind)
|
|
local row = one(store:fetchOne(
|
|
"SELECT COUNT(*) AS n FROM content WHERE kind=? AND published=1", kind))
|
|
return row and row.n or 0
|
|
end
|
|
|
|
--- Fetch a single content row by its primary key. Returns nil if missing.
|
|
--- Does NOT include file_data; use get_doc() for binary blobs.
|
|
function M.get_by_id(id)
|
|
return one(store:fetchOne(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content WHERE id=?", id))
|
|
end
|
|
|
|
--- Every row across all kinds (admin dashboard).
|
|
function M.list_all()
|
|
return many(store:fetchAll(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content ORDER BY kind, created_at DESC"))
|
|
end
|
|
|
|
--- Most recently updated published rows across all kinds, newest first.
|
|
function M.list_recent(n)
|
|
return many(store:fetchAll(
|
|
"SELECT " .. CONTENT_COLS .. " FROM content WHERE published=1 ORDER BY updated_at DESC LIMIT ?", n or 5))
|
|
end
|
|
|
|
--- Fetch a published docs entry including its binary blob.
|
|
--- Pass include_drafts=true (admin) to also match drafts.
|
|
--- Only call this when you actually need to serve the file.
|
|
function M.get_doc(slug, include_drafts)
|
|
local sql = "SELECT id, kind, slug, title, mime_type, file_data, created_at, updated_at"
|
|
.. " FROM content WHERE kind='docs' AND slug=?"
|
|
if not include_drafts then sql = sql .. " AND published=1" end
|
|
return one(store:fetchOne(sql, slug))
|
|
end
|
|
|
|
--- Insert a new docs entry (binary file). Returns the new row id.
|
|
function M.insert_doc(slug, title, mime_type, file_data, published)
|
|
local id, err = store:execute([[
|
|
INSERT INTO content (kind, slug, title, body, mime_type, file_data, published)
|
|
VALUES ('docs', ?, ?, '', ?, ?, ?)
|
|
]], slug, title, mime_type or "", file_data or "", published == 1 and 1 or 0)
|
|
assert(id, err)
|
|
return id
|
|
end
|
|
|
|
--- Substring search across title and body using LIKE.
|
|
--- Pass kind to restrict to one section, or omit for a global search.
|
|
--- Pass tag to restrict to rows whose tags comma-list contains that tag exactly.
|
|
--- Returns rows with an `excerpt` column (first 200 chars of body).
|
|
--- TODO: replace with FTS5 snippet() once a build with FTS5 is available.
|
|
function M.search(q, kind, tag)
|
|
local has_q = q and not q:match("^%s*$")
|
|
if not has_q and not tag then return {} end
|
|
|
|
local wheres = {"published=1", "kind!='docs'"}
|
|
local vals = {}
|
|
|
|
if has_q then
|
|
local pattern = "%" .. q:gsub("[%%_]", "%%%1") .. "%"
|
|
wheres[#wheres+1] = "(title LIKE ? OR body LIKE ?)"
|
|
vals[#vals+1] = pattern
|
|
vals[#vals+1] = pattern
|
|
end
|
|
|
|
if kind then
|
|
wheres[#wheres+1] = "kind=?"
|
|
vals[#vals+1] = kind
|
|
end
|
|
|
|
if tag then
|
|
-- Exact comma-boundary match: `,tag,` inside `,tags_string,`
|
|
wheres[#wheres+1] = "(',' || tags || ',') LIKE ?"
|
|
vals[#vals+1] = "%," .. tag .. ",%"
|
|
end
|
|
|
|
local sql = "SELECT id, kind, slug, title, summary, tags, published, created_at,"
|
|
.. " substr(body, 1, 200) AS excerpt"
|
|
.. " FROM content WHERE " .. table.concat(wheres, " AND ")
|
|
.. " ORDER BY created_at DESC"
|
|
|
|
return many(store:fetchAll(sql, table.unpack(vals)))
|
|
end
|
|
|
|
--- Return a sorted list of {tag, count} pairs for published content.
|
|
--- Pass kind to restrict to one section, or omit for all kinds.
|
|
function M.list_tags(kind)
|
|
local rows
|
|
if kind then
|
|
rows = many(store:fetchAll(
|
|
"SELECT tags FROM content WHERE kind=? AND published=1 AND tags!=''", kind))
|
|
else
|
|
rows = many(store:fetchAll(
|
|
"SELECT tags FROM content WHERE published=1 AND tags!=''"))
|
|
end
|
|
local counts = {}
|
|
for _, row in ipairs(rows) do
|
|
for tag in row.tags:gmatch("[^,]+") do
|
|
tag = tag:match("^%s*(.-)%s*$")
|
|
if tag ~= "" then
|
|
counts[tag] = (counts[tag] or 0) + 1
|
|
end
|
|
end
|
|
end
|
|
local list = {}
|
|
for tag, count in pairs(counts) do
|
|
list[#list+1] = { tag=tag, count=count }
|
|
end
|
|
table.sort(list, function(a, b) return a.tag < b.tag end)
|
|
return list
|
|
end
|
|
|
|
--- Insert a new content row. Returns the new row id.
|
|
--- tags is an optional comma-separated string (e.g. "lua,web,programming").
|
|
function M.insert_content(kind, slug, title, body, summary, published, tags)
|
|
local id, err = store:execute([[
|
|
INSERT INTO content (kind, slug, title, body, summary, published, tags)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
]], kind, slug, title, body, summary or "", published == 1 and 1 or 0, tags or "")
|
|
assert(id, err)
|
|
return id
|
|
end
|
|
|
|
--- Update fields on an existing row by id.
|
|
--- Accepted field keys: kind, slug, title, body, summary, published, tags, mime_type, file_data.
|
|
function M.update_content(id, fields)
|
|
local allowed = { kind=1, slug=1, title=1, body=1, summary=1, published=1, tags=1,
|
|
mime_type=1, file_data=1 }
|
|
local sets, vals = {}, {}
|
|
for k, v in pairs(fields) do
|
|
if allowed[k] then
|
|
sets[#sets + 1] = k .. "=?"
|
|
vals[#vals + 1] = v
|
|
end
|
|
end
|
|
if #sets == 0 then return end
|
|
sets[#sets + 1] = "updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')"
|
|
vals[#vals + 1] = id
|
|
local ok, err = store:execute(
|
|
"UPDATE content SET " .. table.concat(sets, ", ") .. " WHERE id=?",
|
|
table.unpack(vals))
|
|
assert(ok, err)
|
|
end
|
|
|
|
--- Delete a content row by id.
|
|
function M.delete_content(id)
|
|
local ok, err = store:execute("DELETE FROM content WHERE id=?", id)
|
|
assert(ok, err)
|
|
end
|
|
|
|
-- ── Settings ───────────────────────────────────────────────────────────
|
|
|
|
--- Fetch a setting value by key. Returns the string value, or nil if missing.
|
|
function M.get_setting(key)
|
|
local row = one(store:fetchOne("SELECT value FROM settings WHERE key=?", key))
|
|
return row and row.value or nil
|
|
end
|
|
|
|
--- Insert or update a setting by key.
|
|
function M.set_setting(key, value)
|
|
local ok, err = store:execute([[
|
|
INSERT INTO settings(key, value) VALUES(?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
]], key, value)
|
|
assert(ok, err)
|
|
end
|
|
|
|
-- ── Sessions ───────────────────────────────────────────────────────────
|
|
|
|
--- Persist a new session token with its expiry (unix timestamp).
|
|
function M.create_session(token, expires_at)
|
|
local ok, err = store:execute(
|
|
"INSERT INTO sessions(token, expires_at) VALUES(?, ?)", token, expires_at)
|
|
assert(ok, err)
|
|
end
|
|
|
|
--- Fetch a session by token, checking that it has not expired.
|
|
--- Returns the row (with .token and .expires_at), or nil.
|
|
function M.get_session(token)
|
|
return one(store:fetchOne(
|
|
"SELECT token, expires_at FROM sessions WHERE token=? AND expires_at > ?",
|
|
token, os.time()))
|
|
end
|
|
|
|
--- Remove a session by token (logout).
|
|
function M.delete_session(token)
|
|
store:execute("DELETE FROM sessions WHERE token=?", token)
|
|
end
|
|
|
|
--- Remove all expired sessions (housekeeping; cheap to call on each login).
|
|
function M.prune_sessions()
|
|
store:execute("DELETE FROM sessions WHERE expires_at <= ?", os.time())
|
|
end
|
|
|
|
return M
|