Initial commit, blog system works
This commit is contained in:
314
.lua/db.lua
Normal file
314
.lua/db.lua
Normal file
@@ -0,0 +1,314 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user