feat(database): support browsing and content management

Add search, pagination, channel management, ownership checks and reversible content hiding, with automatic schema upgrades.
This commit is contained in:
rekushi committed 2026-10-29 04:21:31 +00:00
1 parent b7b38ad0be
commit b776549445
1 file changed
+203 -37
+203 -37
View File
@@ -31,7 +31,7 @@ class DatabaseBackend:
"""Small SQLite backend for RexiMemo: Open Source Edition.
The OSE deliberately does not contain production session, device, FSID-proof,
migration, experiment, passkey, Discord or moderation-state tables. Login
migration, experiment, passkey or Discord tables. Login
state is just an IP -> account mapping and bans are just blocked IP rows.
"""
@@ -120,6 +120,17 @@ class DatabaseBackend:
CREATE INDEX IF NOT EXISTS idx_comments_flipnote ON comments(flipnote_id, deleted, posted_at ASC);
"""
)
for table in ("flipnotes", "comments"):
existing = {row[1] for row in self.conn.execute("PRAGMA table_info(" + table + ")")}
for name, definition in (
("description", "TEXT NOT NULL DEFAULT ''"),
("hidden", "INTEGER NOT NULL DEFAULT 0 CHECK(hidden IN (0,1))"),
("moderation_reason", "TEXT NOT NULL DEFAULT ''"),
):
if name not in existing:
self.conn.execute("ALTER TABLE " + table + " ADD COLUMN " + name + " " + definition)
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_flipnotes_visible ON flipnotes(deleted,hidden,posted_at DESC,id DESC)")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_comments_visible ON comments(flipnote_id,deleted,hidden,posted_at,id)")
@staticmethod
def _password_hash(password: str) -> str:
@@ -128,11 +139,13 @@ class DatabaseBackend:
return hashlib.sha256(str(password).encode("utf-8")).hexdigest()
def _seed(self):
from server_config import load_config
configured = bool(load_config().get("setup_complete"))
with self.lock, self.conn:
self.conn.execute(
"INSERT OR IGNORE INTO channels(id,title,description) VALUES(1,'General','Public Flipnotes')"
)
if not self._one("SELECT id FROM accounts WHERE username=?", ("reximemo",)):
if not configured and not self._one("SELECT id FROM accounts WHERE is_admin=1"):
self.conn.execute(
"INSERT INTO accounts(username,password_sha256,is_admin,created_at) VALUES(?,?,1,?)",
("reximemo", self._password_hash("alpine"), _now()),
@@ -295,8 +308,49 @@ class DatabaseBackend:
)
return self.get_creator_room(cur.lastrowid)
def list_creator_flipnotes(self, room_id: int, limit=50, offset=0):
return self.list_flipnotes(room_id=int(room_id), limit=limit, offset=offset)
def list_creator_flipnotes(self, room_id: int, limit=50, offset=0, *, include_hidden=False, q=""):
return self.list_flipnotes(room_id=int(room_id), limit=limit, offset=offset,
include_hidden=include_hidden, q=q)
@staticmethod
def _search_text(q):
return str(q or "").strip()[:100]
def search_creators(self, q="", page=1, per_page=24):
text = self._search_text(q)
size = max(1, min(100, int(per_page)))
offset = (max(1, int(page)) - 1) * size
return self._all(
"SELECT r.*,(SELECT COUNT(*) FROM flipnotes f WHERE f.creator_room_id=r.id AND f.deleted=0 AND f.hidden=0) AS flipnote_count "
"FROM creator_rooms r WHERE (?='' OR instr(lower(r.display_name),lower(?))>0 OR instr(lower(r.fsid),lower(?))>0) "
"AND EXISTS(SELECT 1 FROM flipnotes f WHERE f.creator_room_id=r.id AND f.deleted=0 AND f.hidden=0) "
"ORDER BY r.display_name COLLATE NOCASE,r.id LIMIT ? OFFSET ?",
(text, text, text, size, offset),
)
def count_creators(self, q=""):
text = self._search_text(q)
row = self._one(
"SELECT COUNT(*) AS n FROM creator_rooms r WHERE (?='' OR instr(lower(r.display_name),lower(?))>0 OR instr(lower(r.fsid),lower(?))>0) "
"AND EXISTS(SELECT 1 FROM flipnotes f WHERE f.creator_room_id=r.id AND f.deleted=0 AND f.hidden=0)",
(text, text, text),
)
return int(row["n"])
@staticmethod
def _owns_content(account_id, ip, item):
if not item:
return False
owner = item.get("account_id")
if owner is not None:
return account_id is not None and int(account_id) == int(owner)
return account_id is None and bool(ip) and str(item.get("owner_ip") or "") == str(ip)
def owns_flipnote(self, account_id, ip, flipnote):
return self._owns_content(account_id, ip, flipnote)
def owns_comment(self, account_id, ip, comment):
return self._owns_content(account_id, ip, comment)
# Channels -------------------------------------------------------------
def get_channel(self, channel_id: int):
@@ -305,6 +359,39 @@ class DatabaseBackend:
def list_channels(self):
return self._all("SELECT * FROM channels ORDER BY id")
@staticmethod
def _channel_text(title, description):
title = str(title or "").strip()
description = str(description or "").strip()
if not title or len(title) > 40:
raise ValueError("Channel titles must contain 1–40 characters.")
if len(description) > 500:
raise ValueError("Channel descriptions can contain up to 500 characters.")
return title, description
def create_channel(self, title, description=""):
title, description = self._channel_text(title, description)
with self.lock, self.conn:
cur = self.conn.execute("INSERT INTO channels(title,description) VALUES(?,?)", (title, description))
return int(cur.lastrowid)
def update_channel(self, channel_id, title, description=""):
title, description = self._channel_text(title, description)
with self.lock, self.conn:
cur = self.conn.execute("UPDATE channels SET title=?,description=? WHERE id=?", (title, description, int(channel_id)))
return cur.rowcount > 0
def delete_channel(self, channel_id, target_channel_id=1):
channel_id, target_channel_id = int(channel_id), int(target_channel_id)
if channel_id == 1:
raise ValueError("The default channel cannot be removed.")
if channel_id == target_channel_id or not self.get_channel(target_channel_id):
raise ValueError("Choose another existing channel for these Flipnotes.")
with self.lock, self.conn:
self.conn.execute("UPDATE flipnotes SET channel_id=? WHERE channel_id=?", (target_channel_id, channel_id))
cur = self.conn.execute("DELETE FROM channels WHERE id=?", (channel_id,))
return cur.rowcount > 0
# Flipnotes ------------------------------------------------------------
def FlipnotePath(self, creator_fsid: str, filename: str) -> Path:
return CREATORS_DIR / str(creator_fsid).upper() / (str(filename) + ".ppm")
@@ -325,7 +412,7 @@ class DatabaseBackend:
filename = Path(current_name).stem if current_name else ""
if not fsid or not filename:
return False
if self.get_flipnote(fsid, filename):
if self._one("SELECT id FROM flipnotes WHERE creator_fsid=? AND filename=?", (fsid, filename)):
return False
display_name = str(getattr(tmb, "Username", "") or getattr(tmb, "EditorAuthorName", "") or "Creator")
room = self.get_or_create_creator(ip_address, AccountID, fsid=fsid, display_name=display_name)
@@ -344,39 +431,43 @@ class DatabaseBackend:
path.write_bytes(data)
return fsid, filename, int(cur.lastrowid)
def get_flipnote(self, creator_fsid: str, filename: str):
def get_flipnote(self, creator_fsid: str, filename: str, *, include_hidden=False):
return self._one(
"SELECT f.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id "
"WHERE f.creator_fsid=? AND f.filename=? AND f.deleted=0",
"WHERE f.creator_fsid=? AND f.filename=? AND f.deleted=0" + ("" if include_hidden else " AND f.hidden=0"),
(str(creator_fsid).upper(), str(filename)),
)
def get_flipnote_by_id(self, flipnote_id: int):
def get_flipnote_by_id(self, flipnote_id: int, *, include_hidden=False):
return self._one(
"SELECT f.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id WHERE f.id=? AND f.deleted=0",
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id WHERE f.id=? AND f.deleted=0" + ("" if include_hidden else " AND f.hidden=0"),
(int(flipnote_id),),
)
def get_flipnote_by_public_id(self, public_id: str):
def get_flipnote_by_public_id(self, public_id: str, *, include_hidden=False):
return self._one(
"SELECT f.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id WHERE f.public_id=? AND f.deleted=0",
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id WHERE f.public_id=? AND f.deleted=0" + ("" if include_hidden else " AND f.hidden=0"),
(str(public_id),),
)
def GetFlipnote(self, CreatorID, filename, Store=False):
return self.get_flipnote(CreatorID, filename) or False
def GetFlipnotePPM(self, CreatorID, filename):
def GetFlipnotePPM(self, CreatorID, filename, *, include_hidden=False):
if not self.get_flipnote(CreatorID, filename, include_hidden=include_hidden):
raise FileNotFoundError("Flipnote unavailable")
return self.FlipnotePath(CreatorID, filename).read_bytes()
def GetFlipnoteTMB(self, CreatorID, filename):
def GetFlipnoteTMB(self, CreatorID, filename, *, include_hidden=False):
if not self.get_flipnote(CreatorID, filename, include_hidden=include_hidden):
raise FileNotFoundError("Flipnote unavailable")
with self.FlipnotePath(CreatorID, filename).open("rb") as f:
return f.read(0x6A0)
def list_flipnotes(self, *, sort="new", page=1, per_page=50, limit=None, offset=None, channel_id=None, room_id=None):
def list_flipnotes(self, *, sort="new", page=1, per_page=50, limit=None, offset=None, channel_id=None, room_id=None, include_hidden=False, q=""):
if limit is None:
limit = per_page
if offset is None:
@@ -388,45 +479,78 @@ class DatabaseBackend:
}.get(sort, "f.posted_at DESC, f.id DESC")
where = ["f.deleted=0"]
args = []
if not include_hidden:
where.append("f.hidden=0")
text = self._search_text(q)
if text:
where.append("(instr(lower(f.title),lower(?))>0 OR instr(lower(f.description),lower(?))>0 OR instr(lower(r.display_name),lower(?))>0)")
args.extend((text, text, text))
if channel_id is not None:
where.append("f.channel_id=?"); args.append(int(channel_id))
if room_id is not None:
where.append("f.creator_room_id=?"); args.append(int(room_id))
args.extend((int(limit), int(offset)))
args.extend((max(1, min(5000, int(limit))), max(0, int(offset))))
return self._all(
"SELECT f.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id "
"WHERE %s ORDER BY %s LIMIT ? OFFSET ?" % (" AND ".join(where), order), args,
)
def count_flipnotes(self, *, channel_id=None, room_id=None):
where = ["deleted=0"]
def count_flipnotes(self, *, channel_id=None, room_id=None, include_hidden=False, q=""):
where = ["f.deleted=0"]
args = []
if not include_hidden:
where.append("f.hidden=0")
text = self._search_text(q)
if text:
where.append("(instr(lower(f.title),lower(?))>0 OR instr(lower(f.description),lower(?))>0 OR instr(lower(r.display_name),lower(?))>0)")
args.extend((text, text, text))
if channel_id is not None:
where.append("channel_id=?"); args.append(int(channel_id))
where.append("f.channel_id=?"); args.append(int(channel_id))
if room_id is not None:
where.append("creator_room_id=?"); args.append(int(room_id))
row = self._one("SELECT COUNT(*) AS n FROM flipnotes WHERE " + " AND ".join(where), args)
where.append("f.creator_room_id=?"); args.append(int(room_id))
row = self._one("SELECT COUNT(*) AS n FROM flipnotes f JOIN creator_rooms r ON r.id=f.creator_room_id WHERE " + " AND ".join(where), args)
return int(row["n"] if row else 0)
def update_flipnote(self, flipnote_id, title, description="", channel_id=1):
"""Update details after the caller has checked ownership or staff access."""
title = str(title or "").strip()
description = str(description or "").strip()
if not title or len(title) > 80:
raise ValueError("Titles must contain 1–80 characters.")
if len(description) > 500:
raise ValueError("Descriptions can contain up to 500 characters.")
if not self.get_channel(int(channel_id)):
raise ValueError("Choose an existing channel.")
with self.lock, self.conn:
cur = self.conn.execute("UPDATE flipnotes SET title=?,description=?,channel_id=? WHERE id=? AND deleted=0",
(title, description, int(channel_id), int(flipnote_id)))
return cur.rowcount > 0
def set_flipnote_hidden(self, flipnote_id, hidden, reason=""):
with self.lock, self.conn:
cur = self.conn.execute("UPDATE flipnotes SET hidden=?,moderation_reason=? WHERE id=? AND deleted=0",
(int(bool(hidden)), str(reason or "").strip()[:500] if hidden else "", int(flipnote_id)))
return cur.rowcount > 0
@property
def Newest(self):
return [(r["creator_fsid"], r["filename"]) for r in self.list_flipnotes(limit=5000)]
@property
def Views(self):
row = self._one("SELECT COALESCE(SUM(views),0) AS n FROM flipnotes WHERE deleted=0")
row = self._one("SELECT COALESCE(SUM(views),0) AS n FROM flipnotes WHERE deleted=0 AND hidden=0")
return int(row["n"])
@property
def Stars(self):
row = self._one("SELECT COALESCE(SUM(stars),0) AS n FROM flipnotes WHERE deleted=0")
row = self._one("SELECT COALESCE(SUM(stars),0) AS n FROM flipnotes WHERE deleted=0 AND hidden=0")
return int(row["n"])
def AddView(self, CreatorID, filename, AccountID=None):
with self.lock, self.conn:
cur = self.conn.execute(
"UPDATE flipnotes SET views=views+1 WHERE creator_fsid=? AND filename=? AND deleted=0",
"UPDATE flipnotes SET views=views+1 WHERE creator_fsid=? AND filename=? AND deleted=0 AND hidden=0",
(str(CreatorID).upper(), str(filename)),
)
return cur.rowcount > 0
@@ -434,7 +558,7 @@ class DatabaseBackend:
def AddDownload(self, CreatorID, filename):
with self.lock, self.conn:
cur = self.conn.execute(
"UPDATE flipnotes SET downloads=downloads+1 WHERE creator_fsid=? AND filename=? AND deleted=0",
"UPDATE flipnotes SET downloads=downloads+1 WHERE creator_fsid=? AND filename=? AND deleted=0 AND hidden=0",
(str(CreatorID).upper(), str(filename)),
)
return cur.rowcount > 0
@@ -446,7 +570,7 @@ class DatabaseBackend:
return False
with self.lock, self.conn:
cur = self.conn.execute(
"UPDATE flipnotes SET stars=stars+? WHERE creator_fsid=? AND filename=? AND deleted=0",
"UPDATE flipnotes SET stars=stars+? WHERE creator_fsid=? AND filename=? AND deleted=0 AND hidden=0",
(amount, str(CreatorID).upper(), str(filename)),
)
return cur.rowcount > 0
@@ -458,6 +582,8 @@ class DatabaseBackend:
# Comments -------------------------------------------------------------
def add_text_comment(self, flipnote_id: int, text: str, *, ip_address="", account_id=None):
if not self.get_flipnote_by_id(int(flipnote_id)):
raise ValueError("This Flipnote is unavailable.")
text = " ".join(str(text or "").split())[:500]
if not text:
raise ValueError("Comment is empty.")
@@ -470,6 +596,8 @@ class DatabaseBackend:
return int(cur.lastrowid)
def add_memo_comment(self, flipnote_id: int, ppm: bytes, *, ip_address="", account_id=None):
if not self.get_flipnote_by_id(int(flipnote_id)):
raise ValueError("This Flipnote is unavailable.")
data = bytes(ppm or b"")
tmb = TMB().Read(data)
if not tmb:
@@ -491,32 +619,70 @@ class DatabaseBackend:
self.conn.execute("UPDATE comments SET memo_filename=? WHERE id=?", (name, cid))
return cid
def get_comment(self, comment_id: int):
def get_comment(self, comment_id: int, *, include_hidden=False):
return self._one(
"SELECT c.*,r.public_id AS creator_public_id,r.display_name AS creator_name "
"FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id WHERE c.id=? AND c.deleted=0",
"SELECT c.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id JOIN flipnotes f ON f.id=c.flipnote_id "
"WHERE c.id=? AND c.deleted=0 AND f.deleted=0" + ("" if include_hidden else " AND c.hidden=0 AND f.hidden=0"),
(int(comment_id),),
)
def list_comments(self, flipnote_id: int, limit=100, offset=0):
def list_comments(self, flipnote_id: int, limit=100, offset=0, *, include_hidden=False):
return self._all(
"SELECT c.*,r.public_id AS creator_public_id,r.display_name AS creator_name "
"FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id "
"WHERE c.flipnote_id=? AND c.deleted=0 ORDER BY c.posted_at ASC,c.id ASC LIMIT ? OFFSET ?",
(int(flipnote_id), int(limit), int(offset)),
"SELECT c.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip "
"FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id JOIN flipnotes f ON f.id=c.flipnote_id "
"WHERE c.flipnote_id=? AND c.deleted=0 AND f.deleted=0" + ("" if include_hidden else " AND c.hidden=0 AND f.hidden=0") +
" ORDER BY c.posted_at ASC,c.id ASC LIMIT ? OFFSET ?",
(int(flipnote_id), max(1, min(5000, int(limit))), max(0, int(offset))),
)
def count_comments(self, flipnote_id: int):
row = self._one("SELECT COUNT(*) AS n FROM comments WHERE flipnote_id=? AND deleted=0", (int(flipnote_id),))
def count_comments(self, flipnote_id: int, *, include_hidden=False):
row = self._one("SELECT COUNT(*) AS n FROM comments c JOIN flipnotes f ON f.id=c.flipnote_id "
"WHERE c.flipnote_id=? AND c.deleted=0 AND f.deleted=0" +
("" if include_hidden else " AND c.hidden=0 AND f.hidden=0"), (int(flipnote_id),))
return int(row["n"] if row else 0)
def comment_ppm(self, comment_id: int):
row = self.get_comment(comment_id)
def comment_ppm(self, comment_id: int, *, include_hidden=False):
row = self.get_comment(comment_id, include_hidden=include_hidden)
if not row or row["type"] != "memo" or not row.get("memo_filename"):
return None
path = COMMENTS_DIR / row["memo_filename"]
return path.read_bytes() if path.is_file() else None
def set_comment_hidden(self, comment_id, hidden, reason=""):
with self.lock, self.conn:
cur = self.conn.execute("UPDATE comments SET hidden=?,moderation_reason=? WHERE id=? AND deleted=0",
(int(bool(hidden)), str(reason or "").strip()[:500] if hidden else "", int(comment_id)))
return cur.rowcount > 0
def delete_comment(self, comment_id):
with self.lock, self.conn:
cur = self.conn.execute("UPDATE comments SET deleted=1 WHERE id=? AND deleted=0", (int(comment_id),))
return cur.rowcount > 0
def list_moderation_comments(self, q="", page=1, per_page=25):
text = self._search_text(q)
size = max(1, min(100, int(per_page)))
return self._all(
"SELECT c.*,r.public_id AS creator_public_id,r.display_name AS creator_name,r.account_id,r.owner_ip,"
"f.public_id AS flipnote_public_id,f.title AS flipnote_title,f.hidden AS flipnote_hidden "
"FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id JOIN flipnotes f ON f.id=c.flipnote_id "
"WHERE c.deleted=0 AND f.deleted=0 AND (?='' OR instr(lower(COALESCE(c.text_content,'')),lower(?))>0 "
"OR instr(lower(r.display_name),lower(?))>0 OR instr(lower(f.title),lower(?))>0) "
"ORDER BY c.posted_at DESC,c.id DESC LIMIT ? OFFSET ?",
(text, text, text, text, size, (max(1, int(page)) - 1) * size),
)
def count_moderation_comments(self, q=""):
text = self._search_text(q)
row = self._one(
"SELECT COUNT(*) AS n FROM comments c JOIN creator_rooms r ON r.id=c.creator_room_id JOIN flipnotes f ON f.id=c.flipnote_id "
"WHERE c.deleted=0 AND f.deleted=0 AND (?='' OR instr(lower(COALESCE(c.text_content,'')),lower(?))>0 "
"OR instr(lower(r.display_name),lower(?))>0 OR instr(lower(f.title),lower(?))>0)",
(text, text, text, text),
)
return int(row["n"])
# Minimal admin helpers ------------------------------------------------
def recent_ip_logins(self, limit=50):
return self._all(