bubble/model.py

2066 lines
77 KiB
Python

import datetime
import mariadb
import os
import random
import re
import shutil
import time
from typing import Union
from utils import ago_text, clean_title, parse_at_names, shorten_text, \
GeminiError, UTC, INNER_LINK_PREFIX
def parse_asn1_time(asn1_bytes):
m = re.search(r'^(\d\d\d\d)(\d\d)(\d\d)(\d\d)?(\d\d)?', asn1_bytes.decode('ascii'))
if m:
year, month, day = int(m[1]), int(m[2]), int(m[3])
if m[4] and m[5]:
hour, minute = int(m[4]), int(m[5])
else:
hour, minute = 0, 0
return datetime.datetime(year, month, day, hour, minute, 0)
return None
# NOTE: All enum values are used in database, don't change them!
FOLLOW_USER, FOLLOW_POST, FOLLOW_SUBSPACE = range(3)
class Segment:
TEXT, LINK, IMAGE, ATTACHMENT, POLL = range(5)
def __init__(self, id, post, type, pos, content, url, counter):
self.id = id
self.post = post
self.type = type
self.pos = pos
self.content = content
self.url = url
self.counter = counter
class Notification:
MENTION = 0x0001
LIKE = 0x0002
COMMENT = 0x0004
COMMENT_ON_COMMENTED = 0x0008
COMMENT_ON_FOLLOWED_POST = 0x0010
POST_IN_FOLLOWED_SUBSPACE = 0x0020
POST_BY_FOLLOWED_USER = 0x0040
COMMENT_BY_FOLLOWED_USER = 0x0080
NEW_POLL = 0x0100
ISSUE_CLOSED = 0x0200
ADDED_AS_MODERATOR = 0x0400
REMOVED_AS_MODERATOR = 0x0800
COMMENT_IN_FOLLOWED_SUBSPACE = 0x1000
PRIORITY = {
COMMENT_IN_FOLLOWED_SUBSPACE: 0,
POST_IN_FOLLOWED_SUBSPACE: 1,
COMMENT_BY_FOLLOWED_USER: 2,
POST_BY_FOLLOWED_USER: 3,
NEW_POLL: 4,
COMMENT_ON_FOLLOWED_POST: 5,
COMMENT_ON_COMMENTED: 6,
# Directed at the user.
COMMENT: 10,
MENTION: 11
}
def __init__(self, id, type, dst, src, post, subspace, is_sent, ts,
src_name=None, post_title=None, post_issueid=None, post_summary=None,
post_subname=None, post_subowner=None, subname=None):
self.id = id
self.type = type
self.dst = dst
self.src = src
self.post = post
self.subspace = subspace
self.is_sent = is_sent
self.ts = ts
self.src_name = src_name
self.post_title = post_title
self.post_issueid = post_issueid
self.post_summary = post_summary
self.post_subname = post_subname
self.post_subowner = post_subowner
self.subname = subname
def ymd_date(self, fmt='%Y-%m-%d'):
dt = datetime.datetime.fromtimestamp(self.ts, UTC)
return dt.strftime(fmt)
def age(self):
return ago_text(self.ts)
def entry(self, show_age=True, with_time=False, with_title=True) -> tuple:
"""Returns (link, label) to use in the notification list."""
event = ''
icon = '🔔 '
kind = 'issue' if self.post_issueid else 'post'
if self.type == Notification.LIKE:
event = f'liked your {kind}'
icon = '👍 '
elif self.type == Notification.COMMENT:
event = f'commented on your {kind}'
icon = '💬 '
elif self.type == Notification.COMMENT_ON_COMMENTED:
event = 'replied in discussion'
icon = '💬 '
elif self.type == Notification.COMMENT_ON_FOLLOWED_POST:
event = f'commented on followed {kind}'
icon = '💬 '
elif self.type == Notification.COMMENT_BY_FOLLOWED_USER:
event = f'commented on'
icon = '💬 '
elif self.type == Notification.POST_BY_FOLLOWED_USER:
event = f'published new {kind}'
elif self.type == Notification.POST_IN_FOLLOWED_SUBSPACE:
event = f'posted in {"u/" if self.post_subowner else "s/"}{self.post_subname}'
elif self.type == Notification.COMMENT_IN_FOLLOWED_SUBSPACE:
event = f'commented in {"u/" if self.post_subowner else "s/"}{self.post_subname}'
elif self.type == Notification.MENTION:
event = 'mentioned you'
elif self.type == Notification.NEW_POLL:
event = 'posted a new poll'
icon = '🗳️ '
elif self.type == Notification.ISSUE_CLOSED:
event = f'closed issue'
icon = '✔︎ '
elif self.type == Notification.ADDED_AS_MODERATOR:
event = f'added you as moderator of s/{self.subname}'
elif self.type == Notification.REMOVED_AS_MODERATOR:
event = f'removed you as moderator of s/{self.subname}'
if with_title:
vis_title = self.post_title if self.post_title else \
shorten_text(clean_title(self.post_summary), 50) if self.post_summary else None
if vis_title:
if self.type == Notification.MENTION:
event += ' in'
elif self.type == Notification.COMMENT_ON_COMMENTED:
event += ' about'
elif self.type == Notification.COMMENT_IN_FOLLOWED_SUBSPACE:
event += ', in'
elif self.type == Notification.POST_IN_FOLLOWED_SUBSPACE:
event += ':'
if self.post_issueid:
event += f" #{self.post_issueid}"
event += f' "{vis_title}"'
age = f' · {self.age()}' if show_age else ''
hm_time = f" at {self.ymd_date('%H:%M')}" if with_time else ''
return f'/notif/{self.id}', f'{icon}{self.src_name} {event}{hm_time}{age}'
class User:
# Roles:
BASIC, ADMIN = range(2)
# Sort modes:
SORT_POST_RECENT = 'r'
SORT_POST_HOTNESS = 'h'
SORT_COMMENT_OLDEST = 'o'
SORT_COMMENT_NEWEST = 'n'
# Flags:
HOME_FOLLOWED_FEED_FLAG = 0x1
ASCII_ICONS_FLAG = 0x2
def __init__(self, id, name, info, url, avatar, role, flags, notif, email, email_inter, \
email_range, password, ts_password, ts_created, ts_active, sort_post, sort_cmt):
self.id = id
self.name = name
self.info = info
self.url = url
self.avatar = avatar
self.role = role
self.flags = flags
self.notif = notif
self.email = email
self.email_inter = email_inter
self.email_range = email_range
self.password = password
self.ts_password = ts_password
self.ts_created = ts_created
self.ts_active = ts_active
self.sort_post = sort_post
self.sort_cmt = sort_cmt
def subspace_link(self, prefix=''):
return f'=> /u/{self.name} {self.avatar} {prefix}u/{self.name}\n'
def password_expiry(self):
if self.ts_password:
return max(0, 3600 - (int(time.time()) - self.ts_password))
return None
class Subspace:
OMIT_FROM_ALL_FLAG = 0x1
ISSUE_TRACKER = 0x2
OMIT_FROM_FEED_BY_DEFAULT = 0x4
def __init__(self, id, name, info, url, flags, owner, ts_created, ts_active):
self.id = id
self.name = name
self.info = info
self.url = url
self.flags = flags
self.owner = owner
self.ts_created = ts_created
self.ts_active = ts_active
def title(self):
if self.owner:
return 'u/' + self.name
else:
return 's/' + self.name
def subspace_link(self):
return f'=> /{self.title()} {self.title()}\n'
class Repository:
def __init__(self, id, subspace, clone_url, view_url, idlabel, ts_fetch):
self.id = id
self.subspace = subspace
self.clone_url = clone_url
self.view_url = view_url
self.idlabel = idlabel
self.ts_fetch = ts_fetch
class Commit:
def __init__(self, repo, hash, msg, ts):
self.repo = repo
self.hash = hash
self.msg = msg
self.ts = ts
def ymd_date(self):
dt = datetime.datetime.fromtimestamp(self.ts, UTC)
return dt.strftime('%Y-%m-%d')
def entry(self, view_url, outgoing=False):
if outgoing:
return f'=> {view_url}/{self.hash} Commit {self.hash[:8]} "{shorten_text(clean_title(self.msg), 40)}" · {ago_text(self.ts)}\n'
else:
return f'=> {view_url}/{self.hash} Commit {self.hash[:8]} · {clean_title(self.msg)}\n{ago_text(self.ts)}\n'
class Post:
TAG_PINNED = 'pinned'
TAG_ANNOUNCEMENT = 'announcement' # Admin only
TAG_POLL = 'poll'
TAG_CLOSED = 'closed'
OMIT_FROM_FEED_FLAG = 0x1
def __init__(self, id, subspace, parent, user, issueid, title, flags, is_draft, is_pinned,
num_cmts, num_likes, tags, ts_created, ts_edited, summary,
sub_name=None, sub_owner=None, poster_avatar=None, poster_name=None, num_notifs=0):
self.id = id
self.subspace = subspace
self.parent = parent
self.user = user
self.issueid = issueid
self.title = title
self.flags = flags
self.is_draft = is_draft
self.is_pinned = is_pinned
self.num_cmts = num_cmts
self.num_likes = num_likes
self.tags = tags
self.ts_created = ts_created
self.ts_edited = ts_edited
self.summary = summary
self.sub_name = sub_name
self.sub_owner = sub_owner
self.poster_avatar = poster_avatar
self.poster_name = poster_name
self.num_notifs = num_notifs
def title_text(self):
if len(self.title):
return self.title
elif self.parent:
text = f'Comment on {"u" if self.sub_owner else "s"}/{self.sub_name}/{self.parent}'
#if self.summary:
# text += f': "{shorten_text(self.summary(30))}"'
return text
else:
return '(untitled issue)' if self.issueid else '(untitled post)'
def ymd_date(self):
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
return dt.strftime('%Y-%m-%d')
def ymd_hm_tz(self):
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
return dt.strftime('%Y-%m-%d %H:%M UTC')
def age(self):
if self.is_draft:
return 'Now'
else:
return ago_text(self.ts_created)
def page_url(self):
if self.sub_owner:
return f'/u/{self.poster_name}/{self.id}'
return f'/s/{self.sub_name}/{self.issueid if self.issueid else self.id}'
class Crossref (Post):
def __init__(self, id, subspace, user, issueid, title, tags, ts_created,
sub_name):
super().__init__(id, subspace, None, user, issueid, title, None,
None, None, None, None, tags, ts_created,
None, None, sub_name)
def incoming_entry(self):
x_url = f'/s/{self.sub_name}/{self.id}'
x_icon = '✔︎' if '✔︎' in self.tags else '🐞'
return f'=> {x_url} {x_icon} Mentioned in #{self.issueid}: {self.title}\n{ago_text(self.ts_created)}\n'
def outgoing_entry(self):
x_url = f'/s/{self.sub_name}/{self.id}'
x_icon = '✔︎' if '✔︎' in self.tags else '🐞'
return f'=> {x_url} {x_icon} {INNER_LINK_PREFIX}#{self.issueid}: {self.title}\n'
class File:
def __init__(self, id, segment, user, name, mimetype, data,
segment_url=None, segment_label=None, segment_post=None):
self.id = id
self.segment = segment
self.user = user
self.name = name
self.mimetype = mimetype
self.data = data
self.segment_url = segment_url
self.segment_label = segment_label
self.segment_post = segment_post
class Database:
def __init__(self, cfg):
self.cfg = cfg
self.max_summary = 500
self.conn = mariadb.connect(
user=cfg.get('db.user'),
password=cfg.get('db.password'),
host=cfg.get('db.host'),
port=cfg.getint('db.port'),
database=cfg.get('db.name'))
self.conn.autocommit = False
self.repo_cachedir = cfg.get('repo.cachedir', None)
def close(self):
if self.conn:
self.conn.close()
self.conn = None
def create_tables(self, admin_certpass):
db = self.conn.cursor()
db.execute("""CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(30) UNIQUE,
info VARCHAR(1000) DEFAULT '',
url VARCHAR(1000) DEFAULT '',
avatar CHAR(2),
role INT DEFAULT 0,
flags INT DEFAULT 0,
notif INT DEFAULT ?,
email VARCHAR(256) DEFAULT NULL,
email_inter INT DEFAULT 30,
email_range VARCHAR(30) DEFAULT '',
password VARCHAR(128) DEFAULT NULL COLLATE utf8mb4_bin,
ts_password TIMESTAMP DEFAULT '2000-01-01 00:00:00',
ts_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_email TIMESTAMP DEFAULT '2000-01-01 00:00:00',
sort_post CHAR(1) DEFAULT 'r',
sort_cmt CHAR(1) DEFAULT 'n'
)""", (0xffff,))
db.execute("""CREATE TABLE IF NOT EXISTS certs (
user INT NOT NULL,
fp_cert CHAR(64) UNIQUE NOT NULL,
fp_pubkey CHAR(64) NOT NULL,
subject VARCHAR(200) NOT NULL,
ts_until DATETIME NOT NULL,
INDEX (user)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS subspaces (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(30) UNIQUE,
info VARCHAR(1000),
url VARCHAR(1000) DEFAULT '',
flags INT DEFAULT 0,
owner INT DEFAULT 0,
nextissueid INT DEFAULT 1,
ts_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (name, owner)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS mods (
subspace INT,
user INT,
UNIQUE KEY (subspace, user)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS posts (
id INT PRIMARY KEY AUTO_INCREMENT,
subspace INT,
parent INT,
user INT NOT NULL,
issueid INT,
title VARCHAR(1000),
flags INT DEFAULT 0,
is_draft BOOLEAN DEFAULT TRUE,
is_pinned TINYINT(1) DEFAULT 0,
num_cmts INT DEFAULT 0,
num_likes INT DEFAULT 0,
tags VARCHAR(1000) DEFAULT '',
ts_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_edited TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
summary TEXT DEFAULT '',
UNIQUE KEY (subspace, issueid)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS tags (
post INT NOT NULL,
tag VARCHAR(30),
UNIQUE KEY (post, tag),
iNDEX (post)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS segments (
id INT PRIMARY KEY AUTO_INCREMENT,
post INT NOT NULL,
type INT DEFAULT 0,
pos INT DEFAULT 0,
content MEDIUMTEXT DEFAULT NULL,
url VARCHAR(1000) DEFAULT NULL,
counter INT DEFAULT 0
)""")
db.execute("""CREATE TABLE IF NOT EXISTS notifs (
id INT PRIMARY KEY AUTO_INCREMENT,
type INT NOT NULL,
dst INT NOT NULL,
src INT,
post INT,
subspace INT,
is_sent BOOLEAN DEFAULT FALSE,
is_hidden BOOLEAN DEFAULT FALSE,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE KEY (type, dst, src, post),
INDEX (dst),
INDEX (dst, post),
CONSTRAINT no_self_notif CHECK (src!=dst)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS follow (
user INT NOT NULL,
type INT,
target INT DEFAULT NULL,
UNIQUE KEY (user, type, target)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS likes (
user INT NOT NULL,
post INT NOT NULL,
UNIQUE KEY (user, post)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS votes (
user INT NOT NULL,
segment INT NOT NULL,
post INT NOT NULL,
UNIQUE KEY (user, post),
INDEX (post)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS files (
id INT PRIMARY KEY AUTO_INCREMENT,
segment INT,
user INT NOT NULL,
name VARCHAR(1000) DEFAULT '',
mimetype VARCHAR(1000),
data MEDIUMBLOB
)""")
db.execute("""CREATE TABLE IF NOT EXISTS tokens (
user INT NOT NULL,
token CHAR(10) NOT NULL,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (user),
INDEX (token)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS repos (
id INT PRIMARY KEY AUTO_INCREMENT,
subspace INT NOT NULL UNIQUE,
clone_url VARCHAR(300) DEFAULT '',
view_url VARCHAR(300) DEFAULT '',
idlabel VARCHAR(10) DEFAULT 'IssueID',
ts_fetch TIMESTAMP
)""")
db.execute("""CREATE TABLE IF NOT EXISTS commits (
repo INT NOT NULL,
hash CHAR(64) NOT NULL,
msg VARCHAR(200),
ts TIMESTAMP,
UNIQUE INDEX (repo, hash),
INDEX (repo)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS issuerefs (
repo INT NOT NULL,
commit CHAR(64) NOT NULL,
issue INT NOT NULL,
UNIQUE INDEX (repo, commit, issue),
INDEX (repo, commit),
INDEX (repo, issue)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS crossrefs (
subspace INT NOT NULL, -- for convenience
post INT NOT NULL, -- for convenience
segment INT NOT NULL,
issueid INT NOT NULL, -- source issue ID
ts TIMESTAMP NOT NULL, -- source segment's timestamp
dst_post INT NOT NULL,
dst_issueid INT NOT NULL,
UNIQUE INDEX (segment, dst_issueid),
INDEX (segment),
INDEX (issueid),
INDEX (dst_issueid)
)""")
db.execute('INSERT IGNORE INTO users (name, avatar, role, password, ts_password) '
'VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP())',
('admin', '🚀', User.ADMIN, admin_certpass))
db.execute('INSERT IGNORE INTO subspaces (name, info, owner) VALUES (?, ?, 1)',
('admin', 'Administator of the discussion board'))
self.commit()
def commit(self):
return self.conn.commit()
def create_user(self, username, identity):
cur = self.conn.cursor()
cur.execute("""
INSERT INTO users (name, avatar, role)
VALUES (?, ?, ?)""",
(username, '🚀', User.BASIC))
self.commit()
user_id = cur.lastrowid
self.add_certificate(user_id, identity)
cur.execute("INSERT INTO subspaces (name, owner) VALUES (?, ?)", (username, user_id))
self.commit()
def get_user(self, id=None, identity=None, name=None, password=None):
cur = self.conn.cursor()
if identity:
# First try finding via the certificate fingerprint.
cur.execute("SELECT user FROM certs WHERE fp_cert=?", (identity.fp_cert,))
for (id,) in cur:
pass
cond = []
values = []
if id != None:
cond.append('u.id=?')
values.append(id)
elif name != None:
cond.append('u.name=?')
values.append(name)
if password:
cond.append('u.password=?')
values.append(password)
if not cond:
return None
cur.execute(f"""
SELECT
u.id,
u.name,
u.info,
u.url,
u.avatar,
u.role,
u.flags,
u.notif,
u.email,
u.email_inter,
u.email_range,
u.password,
UNIX_TIMESTAMP(u.ts_password),
UNIX_TIMESTAMP(u.ts_created),
UNIX_TIMESTAMP(u.ts_active),
u.sort_post,
u.sort_cmt,
s.id
FROM users u
JOIN subspaces s ON s.owner=u.id
WHERE {' AND '.join(cond)}""", values)
for (id, name, info, url, avatar, role, flags, notif, email, email_inter,
email_range, password, ts_password, ts_created, ts_active,
sort_post, sort_cmt, user_subspace_id) in cur:
user = User(id, name, info, url, avatar, role, flags, notif,
email, email_inter, email_range, password, \
ts_password, ts_created, ts_active, sort_post, sort_cmt)
user.moderated_subspace_ids = [user_subspace_id] + self.get_moderated_subspace_ids(user)
return user
return None
def add_certificate(self, user: Union[User, int], identity):
if isinstance(user, User):
user = user.id
cur = self.conn.cursor()
cur.execute("""
INSERT INTO certs (user, fp_cert, fp_pubkey, subject, ts_until)
VALUES (?, ?, ?, ?, ?)""",
(user,
identity.fp_cert,
identity.fp_pubkey,
identity.subject()['CN'],
parse_asn1_time(identity.cert.get_notAfter())))
self.commit()
def remove_certificate(self, user, fp_cert):
cur = self.conn.cursor()
cur.execute("DELETE FROM certs WHERE user=? AND fp_cert=?", (user.id, fp_cert))
self.commit()
def get_certificates(self, user) -> list:
cur = self.conn.cursor()
cur.execute("SELECT fp_cert, subject, ts_until FROM certs WHERE user=?", (user.id,))
certs = []
for (fp, subject, expiry) in cur:
certs.append((fp, subject, expiry))
return certs
def update_user(self, user: Union[User, int],
avatar=None, name=None, info=None, url=None,
email=None, email_inter=None, email_range=None,
notif=None, flags=None, password=None,
sort_post=None, sort_cmt=None,
active=False):
if type(user) is User:
user = user.id
cur = self.conn.cursor()
stm = []
values = []
if avatar != None:
stm.append('avatar=?')
values.append(avatar)
if name != None:
stm.append('name=?')
values.append(name)
if info != None:
stm.append('info=?')
values.append(info)
if url != None:
stm.append('url=?')
values.append(url)
if password != None:
stm.append('ts_password=CURRENT_TIMESTAMP()')
stm.append('password=?')
values.append(password if password else None)
if email != None:
stm.append('email=?')
values.append(email if email else None)
if email_inter != None:
stm.append('email_inter=?')
values.append(min(max(5, email_inter), 60 * 24))
if email_range != None:
stm.append('email_range=?')
values.append(email_range) # format was validated earlier
if notif != None:
stm.append('notif=?')
values.append(notif)
if flags != None:
stm.append('flags=?')
values.append(flags)
if sort_post:
stm.append('sort_post=?')
values.append(sort_post)
if sort_cmt:
stm.append('sort_cmt=?')
values.append(sort_cmt)
if active:
stm.append('ts_active=CURRENT_TIMESTAMP()')
if stm:
values.append(user)
cur.execute(f"UPDATE users SET {','.join(stm)} WHERE id=?", tuple(values))
if name != None:
cur.execute(f"UPDATE subspaces SET name=? WHERE owner=?", (name, user))
self.commit()
def destroy_user(self, user: User):
# This will already delete all posts made in the user's own subspace.
self.destroy_subspace(self.get_subspace(owner=user.id))
# Delete everything relating to this user.
cur = self.conn.cursor()
cur.execute("DELETE FROM users WHERE id=?", (user.id,))
cur.execute("DELETE FROM certs WHERE user=?", (user.id,))
cur.execute("DELETE FROM mods WHERE user=?", (user.id,))
cur.execute("DELETE FROM tags WHERE post IN (SELECT id FROM posts WHERE user=?)",
(user.id,))
cur.execute("DELETE FROM segments WHERE post IN (SELECT id FROM posts WHERE user=?)",
(user.id,))
cur.execute("DELETE FROM likes WHERE user=? OR post IN (SELECT id FROM posts WHERE user=?)",
(user.id, user.id))
cur.execute("DELETE FROM votes WHERE user=?", (user.id,))
cur.execute("DELETE FROM notifs WHERE src=? OR dst=? OR post IN (SELECT id FROM posts WHERE user=?)", (user.id, user.id, user.id))
cur.execute("DELETE FROM follow WHERE type=? AND target=?", (FOLLOW_USER, user.id))
cur.execute("DELETE FROM files WHERE user=?", (user.id,))
cur.execute("DELETE FROM posts WHERE user=?", (user.id,))
self.commit()
def get_follows(self, user: User) -> set:
cur = self.conn.cursor()
cur.execute("SELECT type, target FROM follow WHERE user=?", (user.id,))
follows = set()
for (type, target) in cur:
follows.add((type, target))
return follows
def modify_follows(self, user: User, is_adding: bool, follow_type, target_id):
cur = self.conn.cursor()
if is_adding:
cur.execute("INSERT IGNORE INTO follow (user, type, target) VALUES (?, ?, ?)",
(user.id, follow_type, target_id))
else:
cur.execute("DELETE FROM follow WHERE user=? AND type=? AND target=?",
(user.id, follow_type, target_id))
self.commit()
def create_file(self, user: User, name: str, mimetype: str, data: bytes) -> int:
cur = self.conn.cursor()
cur.execute("""
INSERT INTO files (segment, user, name, mimetype, data)
VALUES (?, ?, ?, ?, ?)""", (0, user.id, name, mimetype, data))
self.commit()
return cur.lastrowid
def get_file(self, id):
cur = self.conn.cursor()
cur.execute("""
SELECT segment, user, name, mimetype, data
FROM files
WHERE id=?""", (id,))
for (segment, user, name, mimetype, data) in cur:
return File(id, segment, user, name, mimetype, data)
return None
def get_user_files(self, user: User):
cur = self.conn.cursor()
cur.execute("""
SELECT
f.id, f.segment, f.name, f.mimetype, f.data, s.url, s.content, s.post
FROM files f
JOIN segments s ON s.id=f.segment
WHERE user=?
""", (user.id,))
files = []
for (file_id, segment, name, mimetype, data, url, label, post) in cur:
files.append(File(file_id, segment, user.id, name, mimetype, data,
url, label, post))
return files
def get_subspace_files(self, subspace: Subspace):
cur = self.conn.cursor()
cur.execute("""
SELECT
f.id, f.segment, f.user, f.name, f.mimetype, f.data, s.url, s.content, s.post
FROM files f
JOIN segments s ON f.segment=s.id
JOIN posts p ON s.post=p.id
WHERE p.subspace=?
""", (subspace.id,))
files = []
for (file_id, segment, user, name, mimetype, data, url, label, post) in cur:
files.append(File(file_id, segment, user, name, mimetype, data,
url, label, post))
return files
def set_file_segment(self, file_id, segment_id):
cur = self.conn.cursor()
cur.execute("UPDATE files SET segment=? WHERE id=?", (segment_id, file_id))
self.commit()
def create_subspace(self, name: str, mod_id: int):
cur = self.conn.cursor()
cur.execute("""
INSERT INTO subspaces (name)
VALUES (?)
""", (name,))
self.commit()
sub_id = cur.lastrowid
cur.execute("""
INSERT INTO mods (subspace, user)
VALUES (?, ?)
""", (sub_id, mod_id))
self.commit()
def destroy_subspace(self, sub: Subspace):
cur = self.conn.cursor()
cur.execute("DELETE FROM subspaces WHERE id=?", (sub.id,))
cur.execute("DELETE FROM mods WHERE subspace=?", (sub.id,))
cur.execute("DELETE FROM tags WHERE post IN (SELECT id FROM posts WHERE subspace=?)",
(sub.id,))
cur.execute("""DELETE FROM files WHERE segment IN (
SELECT id FROM segments WHERE post IN (
SELECT id FROM posts WHERE subspace=?
)
)""", (sub.id,))
cur.execute("DELETE FROM segments WHERE post IN (SELECT id FROM posts WHERE subspace=?)",
(sub.id,))
cur.execute("DELETE FROM crossrefs WHERE subspace=?", (sub.id,))
cur.execute("DELETE FROM notifs WHERE post IN (SELECT id FROM posts WHERE subspace=?)",
(sub.id,))
cur.execute("DELETE FROM likes WHERE post IN (SELECT id FROM posts WHERE subspace=?)",
(sub.id,))
cur.execute("DELETE FROM votes WHERE post IN (SELECT id FROM posts WHERE subspace=?)",
(sub.id,))
cur.execute("DELETE FROM posts WHERE subspace=?", (sub.id,))
cur.execute("DELETE FROM follow WHERE type=? AND target=?", (FOLLOW_SUBSPACE, sub.id,))
self.commit()
def update_subspace(self, subspace: Union[int, Subspace], info=None, url=None, flags=None,
name=None, active=False):
if type(subspace) is Subspace:
subspace = subspace.id
cur = self.conn.cursor()
stm = []
values = []
if name != None:
stm.append('name=?')
values.append(name)
if info != None:
stm.append('info=?')
values.append(info)
if url != None:
stm.append('url=?')
values.append(url)
if flags != None:
stm.append('flags=?')
values.append(flags)
if active:
stm.append('ts_active=CURRENT_TIMESTAMP()')
if stm:
values.append(subspace)
cur.execute(f"UPDATE subspaces SET {','.join(stm)} WHERE id=?", tuple(values))
self.commit()
def get_subspace(self, id=None, owner=None, name=None):
cur = self.conn.cursor()
if id:
cond = 'id=?'
cond_value = id
elif owner:
cond = 'owner=?'
cond_value = owner if isinstance(owner, int) else owner.id
else:
cond = 'name=?'
cond_value = name
cur.execute(f"""
SELECT
id, name, info, url, flags, owner,
UNIX_TIMESTAMP(ts_created),
UNIX_TIMESTAMP(ts_active)
FROM subspaces
WHERE {cond}""",
(cond_value,))
for (id, name, info, url, flags, owner, ts_created, ts_active) in cur:
return Subspace(id, name, info, url, flags, owner, ts_created, ts_active)
return None
def get_subspaces(self, owner=None, mod=None, locked=False):
cond = []
values = []
cur = self.conn.cursor()
if locked:
cond.append("id NOT IN (SELECT subspace FROM mods) AND owner=0")
if owner != None:
cond.append('owner=?')
values.append(owner)
if mod != None:
cond.append('mods.user=?')
values.append(mod)
cur.execute(f"""
SELECT DISTINCT
id, name, info, url, flags, owner,
UNIX_TIMESTAMP(ts_created), UNIX_TIMESTAMP(ts_active)
FROM subspaces
LEFT JOIN mods ON id=mods.subspace
WHERE {' AND '.join(cond)}
ORDER BY name
""", tuple(values))
subs = []
for (id, name, info, url, flags, owner, ts_created, ts_active) in cur:
subs.append(Subspace(id, name, info, url, flags, owner, ts_created, ts_active))
return subs
def get_moderated_subspace_ids(self, user) -> list:
cur = self.conn.cursor()
cur.execute("SELECT subspace FROM mods WHERE user=?", (user.id,))
subids = []
for (sid,) in cur:
subids.append(sid)
return subids
def get_mods(self, subspace: Union[Subspace, int]):
if type(subspace) is Subspace:
subspace = subspace.id
cur = self.conn.cursor()
cur.execute("""
SELECT
u.id, u.avatar, u.name
FROM mods m JOIN users u ON u.id=m.user
WHERE m.subspace=?
ORDER BY u.name
""", (subspace,))
mods = []
for (id, avatar, name) in cur:
mods.append(User(id, name, None, None, avatar, None, None, None, None, None, None,
None, None, None, None, None, None))
return mods
def modify_mods(self, subspace, actor=None, add=None, remove=None):
cur = self.conn.cursor()
if add:
cur.execute("INSERT INTO mods (subspace, user) VALUES (?, ?)",
(subspace.id, add.id))
if remove:
cur.execute("DELETE FROM mods WHERE subspace=? AND user=?",
(subspace.id, remove.id))
self.commit()
if cur.rowcount > 0:
# Notify the affected (ex-)moderator.
cur.execute("INSERT IGNORE INTO notifs (type, src, dst, subspace) VALUES (?, ?, ?, ?)",
(Notification.ADDED_AS_MODERATOR if add else
Notification.REMOVED_AS_MODERATOR,
actor.id if actor else 0,
add.id if add else remove.id,
subspace.id))
self.commit()
NUM_CMTS_QUERY = """
UPDATE posts
SET num_cmts=(SELECT COUNT(id) FROM posts WHERE parent=?)
WHERE id=?
"""
def create_post(self, user: User, subspace_id: int, parent=0, title=''):
assert type(user) is User
# Unmoderated subspaces become locked.
subspace = self.get_subspace(id=subspace_id)
if subspace.owner == 0 and len(self.get_mods(subspace_id)) == 0:
raise GeminiError(61, "Subspace is locked")
flags = 0
if subspace.flags & Subspace.OMIT_FROM_FEED_BY_DEFAULT:
flags = flags | Post.OMIT_FROM_FEED_FLAG
cur = self.conn.cursor()
cur.execute("""
INSERT INTO posts (subspace, parent, user, title, flags)
VALUES (?, ?, ?, ?, ?)
""", (subspace_id, parent, user.id, title, flags))
self.commit()
post_id = cur.lastrowid
self.update_user(user, active=True)
return post_id
def destroy_post(self, post):
cur = self.conn.cursor()
cur.execute('DELETE FROM files WHERE segment IN (SELECT id FROM segments WHERE post=?)',
(post.id,))
cur.execute('DELETE FROM segments WHERE post=?', (post.id,))
cur.execute('DELETE FROM crossrefs WHERE post=? OR dst_post=?', (post.id, post.id))
cur.execute('DELETE FROM tags WHERE post=?', (post.id,))
cur.execute('DELETE FROM follow WHERE type=? AND target=?', (FOLLOW_POST, post.id))
cur.execute('DELETE FROM notifs WHERE post=?', (post.id,))
cur.execute('DELETE FROM likes WHERE post=?', (post.id,))
cur.execute('DELETE FROM votes WHERE post=?', (post.id,))
cur.execute('DELETE FROM posts WHERE id=?', (post.id,))
# NOTE: Comments on the destroyed post are kept as orphans.
if post.parent:
cur.execute(Database.NUM_CMTS_QUERY, (post.parent, post.parent))
self.commit()
def update_post_summary(self, post):
subspace = self.get_subspace(id=post.subspace)
# Render a concise version of the segments to be displayed in feeds,
# save in the `render` field.
segments = self.get_segments(post)
render = ''
uri_pattern = re.compile(r'(gemini|finger|gopher|mailto|data|file|https|http):(//)?[^ ]+')
# Use only the first link/attachment.
for seg in filter(lambda s: s.type in [Segment.LINK,
Segment.ATTACHMENT], segments):
# No web URLs in the feeds.
if seg.url.lower().startswith('http'):
continue
seg_content = seg.content.strip()
if len(seg_content) == 0:
seg_content = seg.url
if seg_content.startswith('gemini://'):
seg_content = seg_content[9:] # Omit default scheme.
render += f'=> {seg.url} {INNER_LINK_PREFIX}{seg_content}\n'
break
if len(post.title) and not (subspace.flags & Subspace.ISSUE_TRACKER):
render += post.title
with_title = True
else:
with_title = False
first = True
for text in filter(lambda s: s.type == Segment.TEXT, segments):
str = clean_title(text.content)
str = uri_pattern.sub(r'[\1 link]', str)
if len(str) == 0: continue
if with_title and first:
# Separate title from the body text.
render += ''
first = False
render += str + ' '
if len(render) > self.max_summary + 10: # with some buffer for cleaner shortening
break
render = shorten_text(render, self.max_summary)
for seg in filter(lambda s: s.type == Segment.IMAGE, segments):
render += f'\n=> {seg.url} {seg.content}\n'
break
if len(render) and not render.endswith('\n'):
render += '\n'
post.summary = render
cur = self.conn.cursor()
cur.execute("""
UPDATE posts
SET summary=?, ts_edited=CURRENT_TIMESTAMP()
WHERE id=?
""", (render, post.id))
self.commit()
def publish_post(self, post):
if post.is_draft:
cur = self.conn.cursor()
cur.execute("""
UPDATE posts
SET
is_draft=FALSE,
ts_created=CURRENT_TIMESTAMP(),
ts_edited=CURRENT_TIMESTAMP()
WHERE id=?""", (post.id,))
self.commit()
# Notify about a new poll.
if Post.TAG_POLL in self.get_tags(post):
self.notify_new_poll(post)
# Notify about the new published post (but not a comment).
user = self.get_user(id=post.user)
if not post.parent:
self.notify_followers(user, post.id,
Notification.POST_IN_FOLLOWED_SUBSPACE,
FOLLOW_SUBSPACE, post.subspace)
self.notify_followers(user, post.id,
Notification.POST_BY_FOLLOWED_USER,
FOLLOW_USER, user.id)
else:
cur.execute(Database.NUM_CMTS_QUERY, (post.parent, post.parent))
self.notify_commenters(post)
# Notify the author of the parent post. We can do it here because comments are
# never drafted. They become published immediately after creation.
parent_post = self.get_post(id=post.parent)
self.notify_followers(user, parent_post.id,
Notification.COMMENT_IN_FOLLOWED_SUBSPACE,
FOLLOW_SUBSPACE, parent_post.subspace)
self.notify_followers(user, parent_post.id,
Notification.COMMENT_BY_FOLLOWED_USER,
FOLLOW_USER, user.id)
self.notify_followers(user, parent_post.id,
Notification.COMMENT_ON_FOLLOWED_POST,
FOLLOW_POST, parent_post.id)
if parent_post.user != user.id:
# Notify post author of a new comment.
cur.execute("INSERT IGNORE INTO notifs (type, dst, src, post) VALUES (?, ?, ?, ?)",
(Notification.COMMENT, parent_post.user, user.id, parent_post.id))
self.commit()
all_text = []
for segment in self.get_segments(post):
if segment.type == Segment.TEXT:
all_text.append(segment.content)
# Notify mentioned users.
self.notify_mentioned(post, ' '.join(all_text))
if self.get_subspace(post.subspace).flags & Subspace.ISSUE_TRACKER:
if post.issueid is None and post.parent == 0:
# Time to assign a new issue number.
cur.execute("""
UPDATE posts
SET issueid=(SELECT nextissueid FROM subspaces WHERE id=?)
WHERE id=?
""", (post.subspace, post.id))
cur.execute("UPDATE subspaces SET nextissueid=nextissueid+1 WHERE id=?",
(post.subspace,))
self.commit()
# Update all crossrefrences found in text segments (posts and comments).
for segment in self.get_segments(post):
self.update_segment_crossrefs(segment)
self.update_user(post.user, active=True)
self.update_subspace(post.subspace, active=True)
def update_poll_tag(self, post):
cur = self.conn.cursor()
# If the post has a poll, automatically tag it with #poll.
cur.execute("SELECT id FROM segments WHERE type=? AND post=?",
(Segment.POLL, post.id))
is_poll = cur.rowcount > 0
self.modify_tags(post, Post.TAG_POLL, None, add=is_poll)
return is_poll
def create_segment(self, post, type, content=None, url=None) -> int:
cur = self.conn.cursor()
cur.execute("""
INSERT INTO segments (post, type, content, url)
VALUES (?, ?, ?, ?)
""", (post.id, type, content, url))
self.commit()
seg_id = cur.lastrowid
cur.execute("""
UPDATE segments
SET pos=(SELECT MAX(pos) FROM segments) + 1
WHERE id=?
""", (seg_id,))
self.commit()
if content:
# References to other issues.
self.update_segment_crossrefs(self.get_segment(seg_id))
if type == Segment.POLL:
if self.update_poll_tag(post) and not post.is_draft:
# Polls are notified when publishing, but if the post is already public,
# notify right away.
self.notify_new_poll(post)
return seg_id
def get_segment(self, id):
cur = self.conn.cursor()
cur.execute("SELECT post, type, pos, content, url, counter FROM segments WHERE id=?",
(id,))
for (post, type, pos, content, url, counter) in cur:
return Segment(id, post, type, pos, content, url, counter)
return None
def update_segment(self, segment, content=None, url=None):
set_stm = []
values = []
if content != None:
set_stm.append('content=?')
values.append(content)
segment.content = content
if url != None:
set_stm.append('url=?')
values.append(url)
if set_stm:
cur = self.conn.cursor()
values.append(segment.id)
cur.execute(f"UPDATE segments SET {','.join(set_stm)} WHERE id=?", tuple(values))
self.commit()
post = self.get_post(id=segment.post)
if not post.is_draft:
self.update_post_summary(post)
if content:
if not post.is_draft:
self.update_segment_crossrefs(segment)
self.notify_mentioned(post, content)
def get_segments(self, post, poll=None):
cur = self.conn.cursor()
cond = ['post=?']
values = [post.id]
if poll != None:
cond.append(f'type={Segment.POLL}' if poll else f'type!={Segment.POLL}')
cur.execute(f"""
SELECT id, type, pos, content, url, counter
FROM segments
WHERE {' AND '.join(cond)}
ORDER BY pos
""", values)
segments = []
for (id, type, pos, content, url, counter) in cur:
segments.append(Segment(id, post.id, type, pos, content, url, counter))
return segments
def destroy_segment(self, segment):
if segment.type == Segment.POLL:
self.update_poll_tag(self.get_post(id=segment.post))
cur = self.conn.cursor()
cur.execute('DELETE FROM files WHERE segment=?', (segment.id,))
cur.execute('DELETE FROM segments WHERE id=?', (segment.id,))
cur.execute('DELETE FROM crossrefs WHERE segment=?', (segment.id,))
self.commit()
def update_segment_crossrefs(self, segment: Segment):
if segment.type == Segment.TEXT:
# The segment may be in a comment or in a top-level post.
seg_post = self.get_post(id=segment.post)
parent_post = self.get_post(id=seg_post.parent) if seg_post.parent else None
issue_post = parent_post if parent_post else seg_post
if not issue_post.issueid:
return
cur = self.conn.cursor()
cur.execute("DELETE FROM crossrefs WHERE segment=?", (segment.id,))
self.commit()
for dst_issueid in map(int, re.findall(r'(?<!#)#(\d+)', segment.content)):
if dst_issueid == issue_post.issueid:
continue
cur.execute(f"""
INSERT IGNORE INTO crossrefs
(subspace, post, segment, issueid, dst_post, dst_issueid, ts)
SELECT
{issue_post.subspace}, {issue_post.id}, {segment.id},
{issue_post.issueid}, id, {dst_issueid}, ?
FROM posts
WHERE issueid=?
""", (datetime.datetime.fromtimestamp(seg_post.ts_created),
#seg_post.ts_created,
dst_issueid))
self.commit()
def get_issue_crossrefs(self, subspace: Subspace,
incoming_to_issueid=None, outgoing_from_issueid=None):
"""Looks for cross-references originating from `issueid` (a dict of
post IDs -> Posts) or cross-references targeting an issue ID (list of Posts)."""
cond = ['p.subspace=?']
values = [subspace.id]
if outgoing_from_issueid:
cond.append('x.issueid=?')
values.append(outgoing_from_issueid)
else:
cond.append('x.dst_issueid=?')
values.append(incoming_to_issueid)
cond.append('p.is_draft=FALSE') # Not incoming from drafts.
cur = self.conn.cursor()
q = f"""
SELECT
p.id, seg.post, p.user, p.issueid, p.title, p.tags,
UNIX_TIMESTAMP(x.ts), s.name
FROM crossrefs x
JOIN posts p ON {"p.id=x.dst_post" if outgoing_from_issueid else "p.id=x.post"}
JOIN segments seg ON x.segment=seg.id
JOIN subspaces s ON p.subspace=s.id
WHERE {' AND '.join(cond)}
ORDER BY x.ts
"""
cur.execute(q, values)
xrefs = [] if incoming_to_issueid else {}
for (id, segpost_id, user_id, xissueid, title, tags, ts_created, sub_name) in cur:
x = Crossref(id, subspace.id, user_id, xissueid, title, tags,
ts_created, sub_name)
if incoming_to_issueid:
xrefs.append(x)
else:
if x.id not in xrefs:
xrefs[segpost_id] = [x]
else:
xrefs[segpost_id].append(x)
return xrefs
def move_segment(self, post, moved_segment, new_pos):
is_poll = moved_segment.type == Segment.POLL
# Poll options are segments but are handled in a separate listing.
segments = list(filter(lambda s: s.id != moved_segment.id and \
(is_poll and s.type == Segment.POLL or
not is_poll and s.type != Segment.POLL),
self.get_segments(post)))
segments = segments[:new_pos] + [moved_segment] + segments[new_pos:]
cur = self.conn.cursor()
pos = 0
for seg in segments:
cur.execute('UPDATE segments SET pos=? WHERE id=?', (pos, seg.id))
pos += 1
self.commit()
def modify_vote(self, user: User, segment: Segment):
cur = self.conn.cursor()
# Remove the old vote.
cur.execute("SELECT segment FROM votes WHERE user=? AND post=?",
(user.id, segment.post))
for (old_id,) in cur:
cur.execute("UPDATE segments SET counter=counter-1 WHERE id=?", (old_id,))
cur.execute("DELETE FROM votes WHERE user=? AND post=?",
(user.id, segment.post))
self.commit()
break
# Apply the new vote.
cur.execute("INSERT INTO votes (user, segment, post) VALUES (?, ?, ?)",
(user.id, segment.id, segment.post))
cur.execute("UPDATE segments SET counter=counter+1 WHERE id=?",
(segment.id,))
# Automatically follow the vote post.
post = self.get_post(id=segment.post)
if user.id != post.user:
self.modify_follows(user, True, FOLLOW_POST, segment.post)
self.commit()
def get_vote(self, user: User, post: Post) -> Segment:
cur = self.conn.cursor()
cur.execute("SELECT segment FROM votes WHERE user=? AND post=?",
(user.id, post.id))
for (seg,) in cur:
return self.get_segment(id=seg)
return None
FOLLOW_FILTER_JOIN = f"""
JOIN follow fol
ON fol.user=? AND
((fol.type={FOLLOW_POST} AND fol.target=p.id) OR
(fol.type={FOLLOW_SUBSPACE} AND fol.target=p.subspace) OR
(fol.type={FOLLOW_USER} AND fol.target=p.user))
"""
def get_posts(self, id=None, subspace=None, comment=None, user=None, draft=None,
parent=None, sort_descending=True, sort_hotness=False,
filter_by_followed=None, filter_issue_status=None,
gemini_feed=False, notifs_for_user=0,
limit=None, page=0):
cur = self.conn.cursor()
where_stm = []
values = [notifs_for_user]
filter = ''
if filter_by_followed:
filter = Database.FOLLOW_FILTER_JOIN
values.append(filter_by_followed.id)
if comment != None:
where_stm.append('p.parent!=0' if comment else 'p.parent=0')
if parent != None:
where_stm.append('p.parent=?')
values.append(parent)
if id != None:
where_stm.append('p.id=?')
values.append(id)
if user != None:
where_stm.append('p.user=?')
values.append(user.id)
if subspace != None:
where_stm.append('p.subspace=?')
values.append(subspace.id)
PIN_ORDER = 'p.is_pinned DESC'
else:
if id is None and user is None and parent is None:
where_stm.append(f'(sub1.flags & {Subspace.OMIT_FROM_ALL_FLAG})=0')
PIN_ORDER = 'p.is_pinned=2 DESC'
if draft != None:
where_stm.append('p.is_draft=?')
values.append(draft)
if filter_issue_status != None:
where_stm.append('p.tags NOT LIKE ?' if filter_issue_status else \
'p.tags LIKE ?')
values.append('%✔︎%')
if gemini_feed:
where_stm.append(f'(p.flags & {Post.OMIT_FROM_FEED_FLAG})=0')
if sort_hotness:
order_by = """(
(10.0 + p.num_cmts * 200.0 + p.num_likes * 50.0) /
((UNIX_TIMESTAMP(CURRENT_TIMESTAMP()) - UNIX_TIMESTAMP(p.ts_created)) / 3600.0)
) DESC"""
else:
order_by = "p.ts_created " + ('DESC' if sort_descending else 'ASC')
if limit:
limit_expr = 'LIMIT ? OFFSET ?'
values.append(limit) # number of results
values.append(page * limit) # first result
else:
limit_expr = ''
cur.execute(f"""
SELECT
p.id,
p.subspace,
p.parent,
p.user,
p.issueid,
p.title,
p.flags,
p.is_draft,
p.is_pinned,
p.num_cmts,
p.num_likes,
p.tags,
UNIX_TIMESTAMP(p.ts_created),
UNIX_TIMESTAMP(p.ts_edited),
p.summary,
sub1.name,
sub2.owner,
u.avatar,
u.name,
(SELECT COUNT(notifs.id)
FROM notifs
WHERE notifs.dst=? AND notifs.post=p.id AND NOT notifs.is_hidden)
FROM posts p
JOIN users u ON p.user=u.id
JOIN subspaces sub1 ON p.subspace=sub1.id
LEFT JOIN subspaces sub2 ON p.subspace=sub2.id AND p.user=sub2.owner
{filter}
WHERE {' AND '.join(where_stm)}
ORDER BY {PIN_ORDER}, {order_by}
{limit_expr}
""", tuple(values))
posts = []
for (id, subspace, parent, user, issueid, title, flags, is_draft, is_pinned, num_cmts, num_likes, tags, ts_created, ts_edited, summary, sub_name, sub_owner, poster_avatar, poster_name, num_notifs) in cur:
posts.append(Post(id, subspace, parent, user, issueid,
title,
flags,
is_draft, is_pinned,
num_cmts, num_likes,
tags,
ts_created, ts_edited, summary,
sub_name=sub_name,
sub_owner=sub_owner,
poster_avatar=poster_avatar,
poster_name=poster_name,
num_notifs=num_notifs))
return posts
def get_post(self, id, draft=None):
posts = self.get_posts(id=id, draft=draft)
if len(posts) > 0:
return posts[0]
return None
def get_post_for_issueid(self, subspace: Subspace, issueid):
cur = self.conn.cursor()
cur.execute("SELECT id FROM posts WHERE issueid=? AND subspace=?",
(issueid, subspace.id))
for (post_id,) in cur:
return self.get_post(id=post_id)
return None
def count_posts(self, user=None, subspace=None, draft=False,
filter_by_followed=None, filter_issue_status=None):
cond = ['p.parent=0'] # no comments
values = []
filter = ''
if filter_by_followed:
filter = Database.FOLLOW_FILTER_JOIN
values.append(filter_by_followed.id)
if user != None:
cond.append('p.user=?')
values.append(user.id)
if subspace != None:
cond.append('p.subspace=?')
values.append(subspace.id)
else:
# Need filter out posts from subspaces that are flagged for omission.
cond.append(f'(s.flags & {Subspace.OMIT_FROM_ALL_FLAG})=0')
if filter_issue_status != None:
cond.append('p.tags NOT LIKE ?' if filter_issue_status else \
'p.tags LIKE ?')
values.append('%✔︎%')
cond.append('p.is_draft=?')
values.append(draft)
cur = self.conn.cursor()
cur.execute(f"""
SELECT COUNT(p.id)
FROM posts p
JOIN subspaces s ON p.subspace=s.id
{filter}
WHERE {' AND '.join(cond)}
""", values)
for (count,) in cur:
return count
return 0
def update_post(self, post, title=None, flags=None, subspace_id=None):
set_stm = []
values = []
if title != None:
set_stm.append('title=?')
values.append(title)
post.title = title
if flags != None:
set_stm.append('flags=?')
values.append(flags)
post.flags = flags
if subspace_id != None:
set_stm.append('subspace=?')
values.append(subspace_id)
if set_stm:
cur = self.conn.cursor()
values.append(post.id)
cur.execute(f"UPDATE posts SET {','.join(set_stm)} WHERE id=?", tuple(values))
self.commit()
if title != None and not post.is_draft:
self.update_post_summary(post)
def notify_mentioned(self, post, content):
names = parse_at_names(content)
if names:
cur = self.conn.cursor()
notif_post_id = post.parent if post.parent else post.id
where_names_cond = f"name IN ({('?,' * len(names))[:-1]})"
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src, post)
SELECT
{Notification.MENTION}, id, {post.user}, {notif_post_id}
FROM users
WHERE id!={post.user} AND {where_names_cond}
""", names)
self.commit()
def notify_commenters(self, new_comment):
"""When `new_comment` is posted, notify other participants of the same thread about it."""
cur = self.conn.cursor()
cur.execute("""
SELECT DISTINCT user
FROM posts
WHERE is_draft=FALSE AND parent=? AND user!=? AND
user!=(SELECT user FROM posts WHERE id=?)
""", (new_comment.parent, new_comment.user, new_comment.parent))
uids = []
for (i,) in cur: uids.append(i)
for uid in uids:
cur.execute("""
INSERT IGNORE INTO notifs (type, dst, src, post)
VALUES (?, ?, ?, ?)
""", (Notification.COMMENT_ON_COMMENTED, uid, new_comment.user, new_comment.parent))
if uids:
self.commit()
def notify_followers(self, actor: User, post_id, notif_type, follow_type, target_id):
cur = self.conn.cursor()
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src, post)
SELECT
{notif_type},
user,
{actor.id},
{post_id}
FROM follow
WHERE type={follow_type} AND target={target_id}
""")
self.commit()
def notify_new_poll(self, post: Post):
cur = self.conn.cursor()
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src, post)
SELECT
{Notification.NEW_POLL}, id, ?, ?
FROM users
WHERE (notif & {Notification.NEW_POLL})!=0 AND id!=?
""", (post.user, post.id, post.user))
self.commit()
def count_notifications(self, user):
return len(self.get_notifications(user, expire=False))
def modify_likes(self, user, post, add=True):
cur = self.conn.cursor()
if add:
cur.execute("INSERT INTO likes (user, post) VALUES (?, ?)", (user.id, post.id))
if user.id != post.user:
cur.execute("INSERT IGNORE INTO notifs (type, dst, src, post) VALUES (?, ?, ?, ?)",
(Notification.LIKE, post.user, user.id, post.id))
self.update_user(user, active=True)
else:
cur.execute("DELETE FROM likes WHERE user=? AND post=?", (user.id, post.id))
cur.execute("""
UPDATE posts
SET num_likes=(SELECT COUNT(user) FROM likes WHERE post=?)
WHERE id=?
""", (post.id, post.id))
self.commit()
def get_likes(self, post):
cur = self.conn.cursor()
cur.execute("""
SELECT u.name
FROM likes
JOIN users u ON likes.user=u.id
WHERE likes.post=?
""", (post.id,))
users = []
for (name,) in cur:
users.append(name)
return users
def get_tags(self, post):
cur = self.conn.cursor()
cur.execute("SELECT DISTINCT tag FROM tags WHERE post=? ORDER BY tag", (post.id,))
tags = []
for (tag,) in cur:
tags.append(tag)
return tags
def get_popular_tags(self, subspace: Subspace):
cur = self.conn.cursor()
if subspace:
cond = 'WHERE s.id=?'
values = [subspace.id]
else:
cond = ''
values = tuple()
cur.execute(f"""
SELECT t.tag, COUNT(t.tag)
FROM tags t
JOIN posts p ON p.id=t.post
JOIN subspaces s ON s.id=p.subspace
{cond}
GROUP BY t.tag
ORDER BY COUNT(t.tag) DESC
""", values)
popular = []
for (tag, _) in cur:
popular.append(tag)
return popular
def modify_tags(self, post, tag, actor: User, add=True):
was_changed = False
cur = self.conn.cursor()
if add:
cur.execute("INSERT IGNORE INTO tags (post, tag) VALUES (?, ?)",
(post.id, tag))
was_changed = cur.rowcount > 0
else:
cur.execute("DELETE FROM tags WHERE post=? AND tag=?", (post.id, tag))
was_changed = cur.rowcount > 0
# Notify post author and followers of the closing.
if add and was_changed and tag == Post.TAG_CLOSED and actor:
if post.user != actor.id:
cur.execute("INSERT IGNORE INTO notifs (type, dst, src, post) "
"VALUES (?, ?, ?, ?)",
(Notification.ISSUE_CLOSED, post.user, actor.id, post.id))
self.notify_followers(actor, post.id, Notification.ISSUE_CLOSED, FOLLOW_POST, post.id)
# Update the cached tagline.
tags = self.get_tags(post)
is_pinned = 0
if Post.TAG_PINNED in tags:
is_pinned = 1
elif Post.TAG_ANNOUNCEMENT in tags:
is_pinned = 2
def format_tag(name):
if name == Post.TAG_PINNED:
return '📌'
elif name == Post.TAG_ANNOUNCEMENT:
return '📣'
elif name == Post.TAG_POLL:
return '🗳️'
elif name == Post.TAG_CLOSED:
return '✔︎'
return '#' + name
cur.execute("UPDATE posts SET tags=?, is_pinned=? WHERE id=?",
(' '.join(map(format_tag, tags)), is_pinned, post.id))
self.commit()
return was_changed
def mark_notifications_sent(self, user):
cur = self.conn.cursor()
cur.execute("UPDATE notifs SET is_sent=TRUE WHERE dst=?", (user.id,))
cur.execute("""UPDATE users
SET ts_email=CURRENT_TIMESTAMP(), ts_active=CURRENT_TIMESTAMP()
WHERE id=?""", (user.id,))
self.commit()
def get_notifications(self, user, id=None, post_id=None, include_hidden=False, clear=False,
only_unsent=False, sort_desc=False, expire=True) -> list:
cond = ['dst=?', '(n.type & ?)!=0']
values = [user.id, user.notif]
if id != None:
cond.append('n.id=?')
values.append(id)
if post_id != None:
cond.append('n.post=?')
values.append(post_id)
if not include_hidden:
cond.append('n.is_hidden=FALSE')
if only_unsent:
cond.append('n.is_sent=FALSE')
cur = self.conn.cursor()
cur.execute(f"""
SELECT
n.id, n.type, n.dst, n.src, n.post, n.subspace, n.is_sent, UNIX_TIMESTAMP(n.ts),
u.name, p.title, p.issueid, p.summary, s.name, s.owner, s2.name
FROM notifs n
JOIN users u ON src=u.id
LEFT JOIN posts p ON post=p.id
LEFT JOIN subspaces s ON s.id=p.subspace -- Post subspace
LEFT JOIN subspaces s2 ON s2.id=n.subspace -- Notification subspace (may be null)
WHERE {' AND '.join(cond)}
""", tuple(values))
notifs = []
for (id, type, dst, src, post, subspace, is_sent, ts, src_name, post_title, post_issueid,
post_summary, post_subname, post_subowner, subname) in cur:
notifs.append(Notification(id, type, dst, src, post, subspace, is_sent, ts,
src_name, post_title, post_issueid, post_summary,
post_subname, post_subowner, subname))
notifs = self.resolve_notifications(notifs)
notifs.sort(key=lambda n: n.ts, reverse=sort_desc)
if clear and notifs:
cur.execute(f"""
UPDATE notifs SET is_hidden=TRUE
WHERE id IN ({','.join(map(lambda n: str(n.id), notifs))})
""")
# Delete archived notifications after a week, and disabled notifications immediately.
if expire:
cur.execute("""
DELETE FROM notifs
WHERE
(is_hidden=TRUE AND TIMESTAMPDIFF(DAY, ts, CURRENT_TIMESTAMP())>=7)
OR (is_hidden=FALSE AND dst=? AND (type & ?)=0)
""", (user.id, user.notif))
self.commit()
return notifs
def resolve_notifications(self, notifs):
"""Given a list of notifications, apply priority tiers to discard the less important
ones. The discarded notifications are deleted from the database."""
resolved = []
kept_ids = set()
discarded_ids = set()
# Create a table of (post, src) -> [notifs].
indexed = {}
for notif in notifs:
if not notif.post or notif.type in (Notification.LIKE,
Notification.ISSUE_CLOSED):
# Subspace notifications are resolved as-is.
resolved.append(notif)
kept_ids.add(notif.id)
continue
key = (notif.post, notif.src)
if key not in indexed:
indexed[key] = [notif]
else:
indexed[key].append(notif)
# Keep the highest priority notification with the earliest timestamp about an event.
# TODO: Could merge multiple types into one for a more elaborate description?
for key in indexed:
top_notif = None
top = -1
for notif in indexed[key]:
prio = Notification.PRIORITY[notif.type]
if prio > top or (prio == top and notif.ts < top_notif.ts):
top = prio
top_notif = notif
resolved.append(top_notif)
kept_ids.add(top_notif.id)
for notif in notifs:
if notif.id not in kept_ids:
discarded_ids.add(notif.id)
if discarded_ids:
cur = self.conn.cursor()
cur.execute(f"DELETE FROM notifs WHERE id IN ({','.join(map(str, discarded_ids))})")
self.commit()
return resolved
def get_notification(self, user, id, clear=False) -> Notification:
notif = self.get_notifications(user, id=id, include_hidden=True, clear=clear)
if notif:
return notif[0]
return None
TOKEN_CHARS = [chr(ord('a') + i) for i in range(26)] + \
[chr(ord('A') + i) for i in range(26)] + \
[chr(ord('0') + i) for i in range(10)]
def get_token(self, user: User):
# Reuse a valid token within 5 minutes.
cur = self.conn.cursor()
cur.execute("""
SELECT token
FROM tokens
WHERE user=? AND TIMESTAMPDIFF(MINUTE, ts, CURRENT_TIMESTAMP())<5
LIMIT 1
""", (user.id,))
for (token,) in cur:
return token
# Generate a new token.
token = ''.join([Database.TOKEN_CHARS[random.randint(0, len(Database.TOKEN_CHARS) - 1)]
for _ in range(10)])
cur.execute("INSERT INTO tokens (user, token) VALUES (?, ?)", (user.id, token))
self.commit()
return token
def verify_token(self, user: User, token):
cur = self.conn.cursor()
cur.execute("""
DELETE FROM tokens
WHERE TIMESTAMPDIFF(MINUTE, ts, CURRENT_TIMESTAMP())>=60
""")
self.commit()
cur.execute("""
SELECT user
FROM tokens
WHERE user=? AND token=? COLLATE utf8mb4_bin
""", (user.id, token))
return cur.rowcount > 0
def create_repository(self, subspace) -> int:
cur = self.conn.cursor()
cur.execute("INSERT INTO repos (subspace) VALUES (?)", (subspace.id,))
self.commit()
return cur.lastrowid
def destroy_repository(self, repo: Repository):
cur = self.conn.cursor()
cur.execute("DELETE FROM repos WHERE subspace=?", (repo.subspace,))
cur.execute("DELETE FROM commits WHERE repo=?", (repo.id,))
cur.execute("DELETE FROM issuerefs WHERE repo=?", (repo.id,))
self.commit()
if self.repo_cachedir:
shutil.rmtree(os.path.join(self.repo_cachedir, str(repo.id)), ignore_errors=True)
def update_repository(self, repo: Repository, clone_url=None, view_url=None, idlabel=None):
cur = self.conn.cursor()
stm = []
values = []
if clone_url:
stm.append('clone_url=?')
values.append(clone_url)
stm.append('ts_fetch=NULL')
if idlabel and idlabel != repo.idlabel:
stm.append('idlabel=?')
values.append(idlabel)
# Need to recheck the issue numbers.
cur.execute("DELETE FROM issuerefs WHERE repo=?", (repo.id,))
stm.append('ts_fetch=NULL')
if view_url:
stm.append('view_url=?')
values.append(view_url)
values.append(repo.id)
cur.execute(f"UPDATE repos SET {','.join(stm)} WHERE id=?", values)
self.commit()
def get_repositories(self, id=None, subspace=None):
cond = []
values = []
if id:
cond.append('id=?')
values.append(id)
if subspace:
cond.append('subspace=?')
values.append(subspace.id)
cur = self.conn.cursor()
cur.execute(f"""
SELECT id, subspace, clone_url, view_url, idlabel, UNIX_TIMESTAMP(ts_fetch)
FROM repos
{"WHERE" if cond else ""} {" AND ".join(cond)}
""", values)
repos = []
for (id, sub_id, clone_url, view_url, idlabel, ts_fetch) in cur:
repos.append(Repository(id, sub_id, clone_url, view_url, idlabel, ts_fetch))
return repos
def get_repository(self, id=None, subspace=None):
repos = self.get_repositories(id=id, subspace=subspace)
return repos[0] if repos else None
def count_commits(self, repo: Repository):
if not repo:
return 0
cur = self.conn.cursor()
cur.execute("SELECT COUNT(*) FROM commits WHERE repo=?", (repo.id,))
return cur.fetchone()[0]
def get_commits(self, repo: Repository, issueid):
if not repo:
return []
cur = self.conn.cursor()
cur.execute("SELECT c.hash, c.msg, UNIX_TIMESTAMP(c.ts) "
"FROM commits c JOIN issuerefs i ON i.repo=c.repo AND i.commit=c.hash "
"WHERE c.repo=? AND i.issue=?",
(repo.id, issueid))
commits = []
for (hash, msg, ts) in cur:
commits.append(Commit(repo.id, hash, msg, ts))
return commits
def find_commits_by_hash(self, repo: Repository, likely_hashes: list):
if not repo or not likely_hashes:
return []
cond = []
values = []
for hash_prefix in likely_hashes:
cond.append('hash LIKE ?')
values.append(hash_prefix + '%')
cur = self.conn.cursor()
cur.execute(f"""
SELECT DISTINCT hash, msg, UNIX_TIMESTAMP(ts)
FROM commits
WHERE {' OR '.join(cond)}
""", values)
commits = []
for (hash, msg, ts) in cur:
commits.append(Commit(repo.id, hash, msg, ts))
return commits
class Search:
def __init__(self, db):
self.db = db
self.clear()
def clear(self):
self.context = None
self.terms = []
self.results = []
def run(self, terms, subspace=None, limit=30, page_index=0):
self.clear()
self.context = subspace
self.terms = terms
if len(terms) == 0:
return 0
pterms = [f'%{term}%' for term in terms]
cur = self.db.conn.cursor()
def exact_match(w):
for term in terms:
if w.lower() == term.lower():
return 1
return 0
# Users.
if not subspace:
ulimit = int(limit / 2)
cond = ' AND '.join(['(name LIKE ? OR info LIKE ? OR url LIKE ?)'] * len(terms))
values = []
for pt in pterms:
values += [pt, pt, pt]
cur.execute(f"""
SELECT UNIX_TIMESTAMP(ts_active), id, name, avatar, info, url
FROM users
WHERE {cond}
ORDER BY ts_active DESC
LIMIT {ulimit}
OFFSET {ulimit * page_index}
""", values)
for (ts, id, name, avatar, info, url) in cur:
self.results.append(((exact_match(name), ts),
User(id, name, info, url, avatar, None, None, None,
None, None, None, None, None, ts, None, None, None)))
# Subspaces.
cur.execute(f"""
SELECT UNIX_TIMESTAMP(ts_active), id, name, info, url
FROM subspaces
WHERE owner=0 AND {cond}
ORDER BY ts_active DESC
LIMIT {ulimit}
OFFSET {ulimit * page_index}
""", values)
for (ts, id, name, info, url) in cur:
self.results.append(((exact_match(name), ts),
Subspace(id, name, info, url, None, None, None, ts)))
# Posts (including comments).
cond = []
values = []
if subspace:
cond.append('subspace=' + str(subspace.id))
for pt in pterms:
cond.append('(p.title LIKE ? OR u.name LIKE ? OR s.content LIKE ? OR tags LIKE ? OR s.url LIKE ?)')
values += [pt, pt, pt, pt, pt]
cur.execute(f"""
SELECT DISTINCT
UNIX_TIMESTAMP(ts_edited),
p.id, subspace, parent, user, issueid, title, tags,
summary, s.type, sub.name, sub.owner, u.name, u.avatar
FROM segments s
JOIN posts p ON s.post=p.id
JOIN subspaces sub ON p.subspace=sub.id
JOIN users u ON p.user=u.id
WHERE is_draft=FALSE AND {' AND '.join(cond)}
ORDER BY ts_edited DESC
LIMIT {limit}
OFFSET {page_index * limit}
""", values)
for (ts, id, psub, parent, user, issueid, title, tags, summary, seg_type,
sub_name, sub_owner, poster_name, poster_avatar) in cur:
self.results.append(((0, ts),
Post(id, psub, parent, user, issueid, title, False, False,
None, None, None, tags, ts, ts, summary,
sub_name, sub_owner, poster_avatar, poster_name),
seg_type))
# Sort everything by timestamp.
self.results.sort(key=lambda result: result[0], reverse=True)
return len(self.results)