diff --git a/database/__init__.py b/database/__init__.py index 6a81a64..70c9969 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -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(