bubble/model.py

3034 lines
115 KiB
Python

import datetime
import hashlib
import mariadb
import os
import random
import re
import shutil
import time
from gmcapsule import Identity
from OpenSSL import crypto
from typing import Union
from utils import ago_text, clean_title, parse_at_names, shorten_text, strip_links, \
GeminiError, UTC, INNER_LINK_PREFIX, gemini_fetch, certificate_sha256, pubkey_sha256, \
strip_invalid
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
def address_hash(from_addr):
m = hashlib.sha256()
m.update(from_addr[0].encode('utf-8'))
return m.hexdigest()
# NOTE: All enum values are used in database, don't change them!
FOLLOW_USER, FOLLOW_POST, FOLLOW_SUBSPACE = range(3)
MUTE_USER, MUTE_POST, MUTE_SUBSPACE = range(3)
class LogEntry:
ACCOUNT_CREATED, POST_CREATED = range(2)
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:
# Note the default `notif` mask in create_tables.
MENTION = 0x000001
LIKE = 0x000002
COMMENT = 0x000004
COMMENT_ON_COMMENTED = 0x000008
COMMENT_ON_FOLLOWED_POST = 0x000010
POST_IN_FOLLOWED_SUBSPACE = 0x000020
POST_BY_FOLLOWED_USER = 0x000040
COMMENT_BY_FOLLOWED_USER = 0x000080
NEW_POLL = 0x000100
ISSUE_CLOSED = 0x000200
ADDED_AS_MODERATOR = 0x000400
REMOVED_AS_MODERATOR = 0x000800
COMMENT_IN_FOLLOWED_SUBSPACE = 0x001000
THANKS = 0x002000
REACTION = 0x004000
USER_CREATED = 0x008000
SUBSPACE_CREATED = 0x010000
SUBSPACE_INFO_UPDATED = 0x020000
ROLE_CHANGED = 0x040000
REMINDER = 0x080000
REPORT = 0x100000
USER_RENAMED = 0x200000
USER_FLAIR_CHANGED = 0x400000
ALL_MASK = 0xffffff
# Priority order for merging/overriding notifications.
PRIORITY = {
REACTION: -1, # only one kept
USER_RENAMED: -1,
USER_FLAIR_CHANGED: -1,
LIKE: -1,
THANKS: -1,
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,
SUBSPACE_INFO_UPDATED: 12,
}
def __init__(self, id, type, dst, src, post, subspace, comment, is_sent, ts,
src_name=None, post_title=None, post_issueid=None, post_summary=None,
post_subname=None, post_subowner=None, subname=None, reaction=None,
post_subid=None):
self.id = id
self.type = type
self.dst = dst
self.src = src
self.post = post
self.subspace = subspace
self.comment = comment
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_subid = post_subid
self.post_subname = post_subname
self.post_subowner = post_subowner
self.subname = subname
self.reaction = reaction
def ymd_date(self, fmt='%Y-%m-%d', tz=None):
dt = datetime.datetime.fromtimestamp(self.ts, UTC)
if tz:
dt = dt.astimezone(tz)
return dt.strftime(fmt)
def age(self):
return ago_text(self.ts)
def title_text(self):
return shorten_text(self.post_title, 50) if self.post_title \
else shorten_text(strip_links(clean_title(self.post_summary)), 50) if self.post_summary \
else None
def entry(self, show_age=True, with_time=False, with_title=True, tz=None) -> tuple:
"""Returns (link, label) to use in the notification list."""
event = ''
icon = '🔔 '
kind = 'issue' if self.post_issueid else 'post'
with_src = True
if self.type == Notification.LIKE:
event = f'liked your {kind}'
icon = '👍 '
elif self.type == Notification.THANKS:
event = f'thanked you'
icon = '🙏 '
elif self.type == Notification.REACTION:
event = f'reacted to your {kind}'
icon = (self.reaction if self.reaction else '🔔') + ' '
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}'
elif self.type == Notification.USER_CREATED:
event = 'created an account'
icon = '👤 '
elif self.type == Notification.USER_RENAMED:
event = f'renamed their account (ID:{self.src})'
icon = '👤 '
elif self.type == Notification.USER_FLAIR_CHANGED:
event = 'changed their flair'
icon = '📛 '
elif self.type == Notification.SUBSPACE_CREATED:
event = f'created the subspace s/{self.subname}'
icon = '🌘 '
elif self.type == Notification.SUBSPACE_INFO_UPDATED:
event = f'updated info about s/{self.subname}'
icon = '✏️ '
elif self.type == Notification.ROLE_CHANGED:
event = "granted your account full access"
elif self.type == Notification.REMINDER:
icon = '🔔'
event = "Reminder"
with_src = False
elif self.type == Notification.REPORT:
icon = '⚠️ '
event = f"reported the {kind}"
if with_title:
vis_title = self.title_text()
if vis_title:
if self.type == Notification.MENTION:
event += ' in'
elif self.type == Notification.THANKS:
event += ' for'
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 |
Notification.REMINDER):
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', tz)}" if with_time else ''
return f'/notif/{self.id}', f'{icon}{self.src_name if with_src else ""} {event}{hm_time}{age}'
class Flair:
TYPES = [
('🛂', 'Note from moderator'),
('👤', 'Self description'),
('🏝️', 'Absence'),
('🗣️', 'Interaction style'),
('✍️', 'Writing style'),
('', 'Custom note'),
]
ENC_SYMBOLS = ['M', 'd', 'a', 'i', 'w', 'n']
MODERATOR_NOTE = 0
SELF_DESCRIPTION = 1
CUSTOM_NOTE = 5
def __init__(self, scope, flair_type, label, is_admin_assigned):
assert(flair_type < len(Flair.TYPES))
self.scope = scope
self.type = flair_type
self.label = label
self.is_admin_assigned = is_admin_assigned
def icon(self):
return Flair.TYPES[self.type][0]
def description(self):
return Flair.TYPES[self.type][1]
class User:
# Roles:
BASIC, ADMIN, LIMITED = range(3)
# Sort modes:
SORT_POST_RECENT = 'r'
SORT_POST_HOTNESS = 'h'
SORT_POST_ACTIVITY = 'a'
SORT_POST_FLAT = 'f'
SORT_COMMENT_OLDEST = 'o'
SORT_COMMENT_NEWEST = 'n'
# Flags:
HOME_FOLLOWED_FEED_FLAG = 0x0001
ASCII_ICONS_FLAG = 0x0002
HIDE_LIKES_FLAG = 0x0004
HIDE_REACTIONS_FLAG = 0x0008
HIDE_THANKS_FLAG = 0x0010
SHORT_PREVIEW_FLAG = 0x0020
DISABLE_ROTATION_FLAG = 0x0040
HOME_NO_USERS_FEED_FLAG = 0x0080
HOME_USERS_FEED_FLAG = 0x0100
COMPOSER_SPLIT_FLAG = 0x0200
HIDE_FLAIRS_FLAG = 0x0400
FEED_MASK = HOME_FOLLOWED_FEED_FLAG | HOME_USERS_FEED_FLAG | HOME_NO_USERS_FEED_FLAG
def __init__(self, id, name, info, flair, url, recovery, avatar, role, flags, notif, \
email, email_inter, email_range, \
password, ts_password, ts_created, ts_active, sort_post, sort_cmt,
timezone):
self.id = id
self.name = name
self.info = info
self.flair = flair
self.url = url
self.recovery = recovery
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
self.timezone = timezone
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
def parse_flair(user_flair):
flairs = []
for flair in user_flair.split('\n'):
try:
flags, scope, enc_type, label = flair.split('\t')
is_admin_assigned = (flags == '*')
scope = int(scope)
type = Flair.ENC_SYMBOLS.index(enc_type)
flairs.append(Flair(scope, type, label, is_admin_assigned))
except Exception as x:
print(x)
flairs = list(sorted(flairs, key=lambda f: (f.scope, f.type, f.label)))
return flairs
def unparse_flair(flairs):
result = []
for f in flairs:
assert(type(f.scope) == int)
#icon = f'{f.icon} ' if f.icon else ''
label = f.label.replace('\t', '') # remove separators
result.append(f"{'*' if f.is_admin_assigned else ''}\t{f.scope}\t{Flair.ENC_SYMBOLS[f.type]}\t{label}")
return '\n'.join(result)
def render_flair(user_flair, context, abbreviate=False, long_form=False, db=None,
user_mod=False, user_op=False):
"""
Arguments:
db (Database): database object for looking up subspace names
in the long form.
context (Subspace): where the flair is being shown. If None,
the flair is being shown in the home feed.
abbreviate (bool): user-provided text is omitted and only
icons are shown.
long_form (bool): a description of the flair type is included
and the output is formatted onto multiple lines instead
of being a single line.
"""
out = ''
has_abbrev = False
if user_flair.strip():
for flair in User.parse_flair(user_flair):
#print(user_flair, icon, label, scope)
if long_form:
# Show everything in the long form.
if flair.icon():
out += flair.icon() + ' '
if flair.scope:
subspace = db.get_subspace(id=flair.scope)
if subspace:
scope = f" (in {subspace.title()})"
else:
scope = " (in a deleted subspace)"
else:
scope = ''
if flair.icon():
out += f"{flair.description()}: "
elif not scope and flair.is_admin_assigned:
out += '📛 Assigned flair: '
else:
out += '📛 Personal flair: '
out += f"{flair.label}{scope}{' (set by admin)' if flair.is_admin_assigned else ''}\n"
elif flair.scope == 0 or (context and flair.scope == context.id):
if abbreviate:
if not flair.icon() or flair.type == Flair.SELF_DESCRIPTION:
has_abbrev = True
continue
# Just showing icons and "...".
out += flair.icon()
# elif flair.label:
# has_abbrev = True
else:
# Showing icons and labels.
if len(out):
out += ', '
icon = flair.icon() if flair.type != Flair.SELF_DESCRIPTION else ''
out += icon
if flair.label:
if icon: out += ' '
out += flair.label
if not long_form:
if user_op or user_mod:
if len(out):
out = ', ' + out
if user_op and user_mod:
out = 'OP/mod' + out
elif user_op:
out = 'OP' + out
elif user_mod:
out = 'mod' + out
if has_abbrev:
out += '...'
return out
class Subspace:
OMIT_FROM_ALL_FLAG = 0x1
ISSUE_TRACKER = 0x2
OMIT_FROM_FEED_BY_DEFAULT = 0x4
HIDE_OMIT_SETTING_FLAG = 0x8
def __init__(self, id, name, info, url, flags, owner, ts_created, ts_active,
latest_post_id=None, num_people=None, num_posts=None, num_cmts=None):
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
self.latest_post_id = latest_post_id
self.num_people = num_people
self.num_posts = num_posts
self.num_cmts = num_cmts
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, tz=None):
dt = datetime.datetime.fromtimestamp(self.ts, UTC)
if tz:
dt = dt.astimezone(tz)
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
OMIT_FROM_ALL_FLAG = 0x2
DISABLE_FEATURED_LINK_FLAG = 0x4
LOCKED_FLAG = 0x8
SORT_CREATED, SORT_ACTIVE, SORT_HOTNESS = range(3)
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,
poster_flair=None, num_notifs=0, num_per_day=None, ts_comment=None):
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.ts_comment = ts_comment
self.summary = summary
self.sub_name = sub_name
self.sub_owner = sub_owner
self.poster_avatar = poster_avatar
self.poster_name = poster_name
self.poster_flair = poster_flair
self.num_notifs = num_notifs
self.num_per_day = num_per_day
def title_text(self):
"""Title shown in the composer."""
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}'
return text
else:
return '(untitled issue)' if self.issueid else '(untitled post)'
def feed_title(self, max_len=100):
if len(self.title) == 0:
return shorten_text(strip_links(clean_title(self.summary), placeholder=False), max_len)
else:
return shorten_text(strip_links(self.title, placeholder=False), max_len)
def quoted_title(self, max_len=60):
return f'"{self.feed_title(max_len)}"'
def ymd_date(self, tz=None):
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
if tz:
dt = dt.astimezone(tz)
return dt.strftime('%Y-%m-%d')
def ymd_hm(self, tz=None, date_fmt='%Y-%m-%d', time_prefix=''):
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
if tz:
dt = dt.astimezone(tz)
return dt.strftime(f'{date_fmt} {time_prefix}%H:%M')
def ymd_hm_tz(self, tz=None):
dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
if tz:
dt = dt.astimezone(tz)
return dt.strftime(f'%Y-%m-%d %H:%M %z')
else:
return dt.strftime(f'%Y-%m-%d %H:%M UTC')
def age(self, tz=None):
if self.is_draft:
return 'Now'
else:
return ago_text(self.ts_created, tz=tz)
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.issueid}'
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.issueid}'
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,
flair VARCHAR(1000) DEFAULT '',
info VARCHAR(1000) DEFAULT '',
url VARCHAR(1000) DEFAULT '',
recovery 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 'o',
timezone VARCHAR(40) DEFAULT 'UTC'
)""", (Notification.ALL_MASK,))
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_people INT DEFAULT 0, -- distinct commenters
num_likes INT DEFAULT 0,
tags VARCHAR(1000) DEFAULT '',
ts_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_edited TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ts_comment TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- time of latest comment
summary TEXT DEFAULT '',
UNIQUE KEY (subspace, issueid),
INDEX (subspace),
INDEX (parent),
INDEX (user),
INDEX (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,
comment INT, -- if not NULL, notification is about a comment (can be linked to)
is_sent BOOLEAN DEFAULT FALSE,
is_hidden BOOLEAN DEFAULT FALSE,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
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),
INDEX (user)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS mute (
user INT NOT NULL,
type INT,
target INT,
UNIQUE KEY (user, type, target),
INDEX (user)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS likes (
user INT NOT NULL,
post INT NOT NULL,
UNIQUE KEY (user, post),
INDEX (post)
)""")
db.execute("""CREATE TABLE IF NOT EXISTS reactions (
user INT NOT NULL,
post INT NOT NULL,
reaction VARCHAR(4) NOT NULL,
UNIQUE KEY (user, post),
INDEX (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("""CREATE TABLE IF NOT EXISTS log (
remote CHAR(64) NOT NULL,
type INT NOT NULL,
ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (remote),
INDEX (type)
)""")
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, role=User.BASIC):
cur = self.conn.cursor()
cur.execute("""
INSERT INTO users (name, avatar, role)
VALUES (?, ?, ?)""",
(username, '🚀', role))
self.commit()
user_id = cur.lastrowid
if identity:
try:
self.add_certificate(user_id, identity)
except:
# Invalid certificate?
cur.execute("DELETE FROM users WHERE id=?", (user_id,))
self.commit()
raise GeminiError(62, 'Certificate not valid')
try:
cur.execute("INSERT INTO subspaces (name, owner) VALUES (?, ?)", (username, user_id))
self.commit()
except:
# Undo the previous database changes.
cur.execute("DELETE FROM users WHERE id=?", (user_id,))
cur.execute("DELETE FROM certs WHERE user=?", (user_id,))
self.commit()
raise GeminiError(50, 'Subspace already exists')
self.notify_admin(Notification.USER_CREATED, user_id)
return user_id
def get_users(self, role, max_age=None, avatar=None, empty_info=None, max_post_count=None):
cur = self.conn.cursor()
cond = ['role=?']
values = [role]
if max_age != None:
cond.append('TIMESTAMPDIFF(SECOND, ts_created, CURRENT_TIMESTAMP())<?')
values.append(max_age)
if avatar != None:
cond.append('avatar=?')
values.append(avatar)
if empty_info != None:
cond.append('info=""')
if max_post_count != None:
cond.append('(SELECT COUNT(*) FROM posts WHERE user=users.id)<=?')
values.append(max_post_count)
cur.execute(f"""
SELECT id, name, avatar, UNIX_TIMESTAMP(ts_created), UNIX_TIMESTAMP(ts_active)
FROM users
WHERE {' AND '.join(cond)}""", values)
users = []
for (id, name, avatar, ts_created, ts_active) in cur:
user = User(id, name, None, None, None, None,
avatar, role, 0, 0, None, None, None, None, None,
ts_created, ts_active, None, None, None)
users.append(user)
return users
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.flair,
u.url,
u.recovery,
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,
u.timezone,
s.id
FROM users u
JOIN subspaces s ON s.owner=u.id
WHERE {' AND '.join(cond)}""", values)
for (id, name, info, flair, url, recovery, avatar, role, flags, notif, email, email_inter,
email_range, password, ts_password, ts_created, ts_active,
sort_post, sort_cmt, timezone, user_subspace_id) in cur:
user = User(id, name, info, flair, url, recovery, avatar, role, flags, notif,
email, email_inter, email_range,
password, ts_password,
ts_created, ts_active,
sort_post, sort_cmt, timezone)
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()
cert = crypto.load_certificate(crypto.FILETYPE_ASN1, identity.cert)
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(cert.get_notAfter())))
self.commit()
def remove_certificate(self, user, fp_cert):
"""If `fp_cert` is NULL, deletes all certificates of the user."""
cur = self.conn.cursor()
if fp_cert is None:
cur.execute("DELETE FROM certs WHERE user=?", (user.id,))
else:
cur.execute("DELETE FROM certs WHERE user=? AND fp_cert=?", (user.id, fp_cert))
self.commit()
def recover_certificate(self, user, identity):
_, _, body = gemini_fetch(user.recovery)
recovered = Identity(crypto.load_certificate(crypto.FILETYPE_PEM, body))
if recovered.fp_cert == identity.fp_cert:
self.add_certificate(user, recovered)
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],
role=None,
avatar=None, name=None, info=None, flair=None,
url=None, recovery=None,
email=None, email_inter=None, email_range=None,
notif=None, flags=None,
password=None, password_expiration_offset_minutes=0,
sort_post=None, sort_cmt=None, timezone=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 role != None:
stm.append('role=?')
values.append(role)
if name != None:
stm.append('name=?')
values.append(name)
if info != None:
stm.append('info=?')
values.append(info)
if flair != None:
stm.append('flair=?')
values.append(flair)
if url != None:
stm.append('url=?')
values.append(url)
if recovery != None:
stm.append('recovery=?')
values.append(recovery)
if password != None:
stm.append(f'ts_password=TIMESTAMPADD(MINUTE, {password_expiration_offset_minutes}, 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 timezone:
stm.append('timezone=?')
values.append(timezone)
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 add_flair(self, user, flair, editor: User):
if editor.role != User.ADMIN:
# Some words are reserved.
RESTRICTED_WORDS = ['OP', 'mod', 'sysop', 'admin']
if flair.type in (Flair.CUSTOM_NOTE, Flair.SELF_DESCRIPTION) and \
flair.label in RESTRICTED_WORDS:
raise GeminiError(61, 'Not authorized')
flairs = [flair]
for f in User.parse_flair(user.flair):
if f.scope == flair.scope and f.type == flair.type:
# The new one replaces this one.
if f.is_admin_assigned and editor.role != User.ADMIN:
raise GeminiError(61, 'Not authorized')
continue
flairs.append(f)
self.update_user(user, flair=User.unparse_flair(flairs))
assert(flair.scope != None)
self.notify_moderators(Notification.USER_FLAIR_CHANGED,
editor,
None,
subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)
def remove_flair(self, user, flair, editor: User):
flairs = []
for f in User.parse_flair(user.flair):
if f.scope == flair.scope and f.type == flair.type:
# This will be removed.
if not f.is_admin_assigned or editor.role == User.ADMIN:
continue
flairs.append(f)
self.update_user(user, flair=User.unparse_flair(flairs))
assert(flair.scope != None)
self.notify_moderators(Notification.USER_FLAIR_CHANGED,
editor,
None,
subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)
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 reactions 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 get_mutes(self, user: User) -> set:
cur = self.conn.cursor()
cur.execute("SELECT type, target FROM mute WHERE user=?", (user.id,))
mutes = set()
for (type, target) in cur:
mutes.add((type, target))
return mutes
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 modify_mutes(self, user: User, is_adding: bool, mute_type, target_id):
cur = self.conn.cursor()
if is_adding:
cur.execute("INSERT IGNORE INTO mute (user, type, target) VALUES (?, ?, ?)",
(user.id, mute_type, target_id))
else:
cur.execute("DELETE FROM mute WHERE user=? AND type=? AND target=?",
(user.id, mute_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 get_time_files(self, ts_range):
cur = self.conn.cursor()
cur.execute("""
SELECT
f.id, f.segment, f.name, f.mimetype, f.data, s.url, s.content, s.post, p.user
FROM files f
JOIN segments s ON s.id=f.segment
JOIN posts p ON s.post=p.id
WHERE UNIX_TIMESTAMP(p.ts_edited)>=? AND UNIX_TIMESTAMP(p.ts_edited)<?
""", ts_range)
files = []
for (file_id, segment, name, mimetype, data, url, label, post, post_user) in cur:
files.append(File(file_id, segment, post_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()
self.modify_follows(self.get_user(id=mod_id), True, FOLLOW_SUBSPACE, sub_id)
self.notify_admin(Notification.SUBSPACE_CREATED, mod_id, sub_id)
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, actor_id=None):
need_notify = False
if type(subspace) is Subspace:
subspace = subspace.id
cur = self.conn.cursor()
stm = []
values = []
if name != None:
stm.append('name=?')
values.append(name)
need_notify = True
if info != None:
stm.append('info=?')
values.append(info)
need_notify = True
if url != None:
stm.append('url=?')
values.append(url)
need_notify = True
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()
if need_notify and actor_id:
self.notify_admin(Notification.SUBSPACE_INFO_UPDATED, actor_id, subspace)
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 DISTINCT 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),
(SELECT p.id
FROM posts p
WHERE p.subspace=sub.id AND p.parent=0 AND p.is_draft=FALSE
ORDER BY p.ts_created DESC
LIMIT 1
) AS latest_post_id,
(SELECT COUNT(DISTINCT(p.user))
FROM posts p
WHERE p.subspace=sub.id AND p.is_draft=FALSE
) AS num_people,
(SELECT COUNT(p.id)
FROM posts p
WHERE p.subspace=sub.id AND p.parent=0 AND p.is_draft=FALSE
) AS num_posts,
(SELECT COUNT(p.id)
FROM posts p
WHERE p.subspace=sub.id AND p.parent!=0 AND p.is_draft=FALSE
) AS num_cmts
FROM subspaces sub
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,
latest_post_id, num_people, num_posts, num_cmts) in cur:
subs.append(Subspace(id, name, info, url, flags, owner, ts_created, ts_active,
latest_post_id=latest_post_id,
num_people=num_people,
num_posts=num_posts,
num_cmts=num_cmts))
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, UNIX_TIMESTAMP(u.ts_active)
FROM mods m JOIN users u ON u.id=m.user
WHERE m.subspace=?
ORDER BY u.name
""", (subspace,))
mods = []
for (id, avatar, name, ts_active) in cur:
mods.append(User(id, name, None, None, None, None, avatar, None, None, None, None,
None, None, None, None, None, ts_active, 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.
src = actor.id if actor else 0
dst = add.id if add else remove.id
if src != dst:
cur.execute("INSERT IGNORE INTO notifs (type, src, dst, subspace) VALUES (?, ?, ?, ?)",
(Notification.ADDED_AS_MODERATOR if add
else Notification.REMOVED_AS_MODERATOR,
src,
dst,
subspace.id))
self.commit()
if add:
# Moderators should automatically follow their subspaces.
self.modify_follows(add, True, FOLLOW_SUBSPACE, subspace.id)
NUM_CMTS_QUERY = """
UPDATE posts
SET num_cmts=(SELECT COUNT(id) FROM posts WHERE parent=?),
num_people=(SELECT COUNT(DISTINCT user) 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 \
and user.role != User.ADMIN:
raise GeminiError(61, "Subspace is locked")
if user.role == User.LIMITED and subspace.owner != user.id:
return GeminiError(61, "Not authorized")
flags = 0
if subspace.flags & Subspace.OMIT_FROM_FEED_BY_DEFAULT:
flags = flags | Post.OMIT_FROM_FEED_FLAG
if user.role == User.LIMITED:
flags |= Post.OMIT_FROM_ALL_FLAG | 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 reactions 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, 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 = ''
# Use only the first link/attachment.
if not post.flags & Post.DISABLE_FEATURED_LINK_FLAG:
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 = strip_links(clean_title(text.content))
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
# Cannot have hashes in the beginning (never a Gemtext header).
while len(render) > 0 and render[0] == '#':
render = render[1:].lstrip()
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 unpublish_post(self, post):
if post.is_draft:
return
if post.num_cmts > 0:
raise GeminiError(50, "Cannot unpublish a post that has comments")
post.is_draft = True
cur = self.conn.cursor()
cur.execute("""
UPDATE posts
SET
is_draft=TRUE,
ts_edited=CURRENT_TIMESTAMP()
WHERE id=?""", (post.id,))
self.commit()
# Undo notifications because drafts don't publicly exist.
cur.execute("DELETE FROM notifs WHERE post=? OR comment=?", (post.id, post.id))
def notify_followed_about_post(self, post: Post):
if not post.parent:
user = self.get_user(id=post.user)
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)
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_followed_about_post(post)
else:
cur.execute(Database.NUM_CMTS_QUERY, (post.parent, post.parent, post.parent))
cur.execute("UPDATE posts SET ts_comment=CURRENT_TIMESTAMP() WHERE id=?",
(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, comment_id=post.id)
self.notify_followers(user, parent_post.id,
Notification.COMMENT_BY_FOLLOWED_USER,
FOLLOW_USER, user.id, comment_id=post.id)
self.notify_followers(user, parent_post.id,
Notification.COMMENT_ON_FOLLOWED_POST,
FOLLOW_POST, parent_post.id, comment_id=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, comment) "
"VALUES (?, ?, ?, ?, ?)",
(Notification.COMMENT, parent_post.user, user.id, parent_post.id, 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: 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 WHERE post=?) + 1
WHERE id=?
""", (post.id, seg_id,))
self.commit()
if content:
# References to other issues.
self.update_segment_crossrefs(self.get_segment(seg_id))
if not post.is_draft:
self.update_post_summary(post)
if content:
self.notify_mentioned(post, content)
if type == Segment.POLL:
if self.update_poll_tag(post):
# Polls are notified when publishing, but if the post is already public,
# notify right away.
self.notify_new_poll(post)
elif type == Segment.POLL:
# Drafts get tagged with "poll" immediately. Notifications sent out when published.
self.update_poll_tag(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(strip_invalid(content))
old_content = segment.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:
self.update_segment_crossrefs(segment)
self.notify_mentioned(post, content, old_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 get_segments_of_similar_type(self, post, is_poll):
return list(filter(lambda s:
(is_poll and s.type == Segment.POLL or
not is_poll and s.type != Segment.POLL),
self.get_segments(post)))
def get_segment_position_as_index(self, post, segment):
"""Gets the position of the segment as displayed in the composer (0-based)."""
pos = 0
for seg in self.get_segments_of_similar_type(post, segment.type == Segment.POLL):
if seg.id == segment.id:
return pos
pos += 1
return None
def destroy_segment(self, segment):
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()
post = self.get_post(id=segment.post)
if not post.is_draft:
self.update_post_summary(post)
if segment.type == Segment.POLL:
self.update_poll_tag(post)
self.unpublish_if_empty(post)
def unpublish_if_empty(self, post):
if not post.is_draft and not post.title and post.num_cmts == 0 and \
len(self.get_segments(post)) == 0:
self.unpublish_post(post)
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),
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,
self.get_segments_of_similar_type(post, is_poll)))
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()
if not post.is_draft:
self.update_post_summary(post)
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
# These joins are used with
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))
"""
MUTE_ALL_COND_JOIN = f"""
LEFT JOIN mute mt
ON mt.user=? AND
mt.user!=p.user AND
((mt.type={MUTE_SUBSPACE} AND mt.target=p.subspace) OR
(mt.type={MUTE_USER} AND mt.target=p.user))
"""
MUTE_SUBSPACE_COND_JOIN = f"""
LEFT JOIN mute mt
ON mt.user=? AND
mt.user!=p.user AND
(mt.type={MUTE_USER} AND mt.target=p.user)
"""
TAG_FILTER_JOIN = "JOIN tags tg ON tg.post=p.id"
def get_posts(self,
id=None,
subspace=None,
comment=None,
user=None,
draft=None,
parent=None,
filter_by_followed=None,
filter_issue_status=None,
filter_tag=None,
omit_user_subspaces=False,
omit_nonuser_subspaces=False,
rotation_group_of_post:Post=None,
rotate_per_day=False,
gemini_feed=False,
notifs_for_user_id=0,
muted_by_user_id=0,
sort=Post.SORT_CREATED,
sort_descending=True,
sort_by_subspace=False,
sort_by_post=False,
ts_range=None,
limit=None,
page=0):
if type(subspace) is Subspace:
subspace = subspace.id
cur = self.conn.cursor()
where_stm = []
values = [notifs_for_user_id]
filter = ''
if filter_by_followed:
filter = Database.FOLLOW_FILTER_JOIN
values.append(filter_by_followed.id)
if muted_by_user_id:
filter += Database.MUTE_ALL_COND_JOIN if subspace is None \
else Database.MUTE_SUBSPACE_COND_JOIN
values.append(muted_by_user_id)
if filter_tag:
filter += Database.TAG_FILTER_JOIN
where_stm.append('tg.tag=?')
values.append(filter_tag)
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 omit_user_subspaces:
where_stm.append('sub1.owner=0')
if omit_nonuser_subspaces:
where_stm.append('sub1.owner!=0')
if subspace != None:
where_stm.append('p.subspace=?')
values.append(subspace)
PIN_ORDER = 'is_pinned DESC, '
elif ts_range != None:
where_stm.append('(UNIX_TIMESTAMP(p.ts_edited)>=? AND UNIX_TIMESTAMP(p.ts_edited)<?)')
values.append(ts_range[0])
values.append(ts_range[1])
PIN_ORDER = ''
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 AND (p.flags & {Post.OMIT_FROM_ALL_FLAG})=0)')
PIN_ORDER = '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 muted_by_user_id:
where_stm.append('mt.user IS NULL')
if gemini_feed:
where_stm.append(f'(p.flags & {Post.OMIT_FROM_FEED_FLAG})=0')
if rotation_group_of_post:
where_stm.append(f'(p.subspace={rotation_group_of_post.subspace} AND DATE(FROM_UNIXTIME({rotation_group_of_post.ts_created}))=DATE(p.ts_created))')
if sort == Post.SORT_ACTIVE:
order_by = "p.ts_comment " + ('DESC' if sort_descending else 'ASC')
elif sort == Post.SORT_HOTNESS:
order_by = """(
(10.0 + num_people * 200.0 + num_likes * 50.0) /
((UNIX_TIMESTAMP(CURRENT_TIMESTAMP()) - ts_comment + 1800.0) / 3600.0)
) DESC"""
else:
order_by = "ts_created " + ('DESC' if sort_descending else 'ASC')
if sort_by_post:
order_by = f"id {'DESC' if sort_descending else 'ASC'}, " + order_by
if sort_by_subspace:
order_by = "sub_name, " + order_by
if limit:
limit_expr = 'LIMIT ? OFFSET ?'
values.append(limit) # number of results
values.append(page * limit) # first result
else:
limit_expr = ''
visible_posts_query = 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) AS ts_created,
UNIX_TIMESTAMP(p.ts_edited) AS ts_edited,
UNIX_TIMESTAMP(p.ts_comment) AS ts_comment,
p.summary,
sub1.name AS sub_name,
sub1.owner, -- sub2.owner,
u.avatar,
u.name AS u_name,
u.flair,
(SELECT COUNT(notifs.id)
FROM notifs
WHERE notifs.dst=? AND notifs.post=p.id AND NOT notifs.is_hidden
) AS num_notif,
p.num_people,
DATE(p.ts_created) AS date_created
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)}
"""
if rotate_per_day:
# Each subspace shows a single post per day, but the visible one rotates as time passes.
now = datetime.datetime.now()
rotator = int((now.hour * 60 + now.minute) / 15)
cur.execute(f"""
WITH visible_posts AS (
{visible_posts_query}
), grouped_posts AS (
SELECT
p.*,
ROW_NUMBER() OVER (
PARTITION BY p.subspace, p.date_created
ORDER BY p.subspace, p.ts_created
) AS row_num
FROM visible_posts p
)
SELECT
p.*,
gr.num_per_day
FROM
grouped_posts p
JOIN (
SELECT
subspace,
date_created,
COUNT(*) AS num_per_day
FROM grouped_posts
GROUP BY subspace, date_created
) gr ON gr.subspace=p.subspace AND gr.date_created=p.date_created
WHERE (p.row_num+{rotator})%gr.num_per_day=0
ORDER BY {PIN_ORDER}{order_by}
{limit_expr}
""", tuple(values))
else:
cur.execute(f"""
WITH visible_posts AS ({visible_posts_query})
SELECT
p.*,
0, -- row_num
NULL -- num_per_day
FROM
visible_posts p
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, ts_comment,
summary, sub_name, sub_owner, poster_avatar, poster_name, flair, num_notifs,
_num_people, _date_created, _row_num, num_per_day) 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,
ts_comment=ts_comment,
sub_name=sub_name,
sub_owner=sub_owner,
poster_avatar=poster_avatar,
poster_name=poster_name,
poster_flair=flair,
num_notifs=num_notifs,
num_per_day=num_per_day))
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 is_empty_subspace(self, subspace):
return self.count_posts(subspace=subspace) == 0
def count_posts(self,
user=None,
subspace=None,
parent_id=None,
draft=False,
is_comment=False,
ignore_omit_flags=False,
omit_user_subspaces=False,
omit_nonuser_subspaces=False,
filter_by_followed=None,
filter_issue_status=None,
filter_tag=None,
muted_by_user_id=0,
rotate_per_day=False):
cond = []
values = []
grouping = ''
filter = ''
if is_comment != None:
cond.append('p.parent!=0' if is_comment else 'p.parent=0')
# elif not parent_id and not draft:
# cond.append('p.parent=0') # no comments
if filter_by_followed:
filter = Database.FOLLOW_FILTER_JOIN
values.append(filter_by_followed.id)
if muted_by_user_id:
filter += Database.MUTE_ALL_COND_JOIN if subspace is None \
else Database.MUTE_SUBSPACE_COND_JOIN
values.append(muted_by_user_id)
cond.append('mt.user IS NULL')
if filter_tag:
filter += Database.TAG_FILTER_JOIN
cond.append('tg.tag=?')
values.append(filter_tag)
if omit_user_subspaces:
cond.append('s.owner=0')
if omit_nonuser_subspaces:
cond.append('s.owner!=0')
if user != None:
cond.append('p.user=?')
values.append(user.id)
if parent_id != None and not draft:
cond.append('p.parent=?')
values.append(parent_id)
if subspace != None:
cond.append('p.subspace=?')
values.append(subspace.id)
elif not draft and not ignore_omit_flags:
# Need filter out posts from subspaces that are flagged for omission.
cond.append(f'((s.flags & {Subspace.OMIT_FROM_ALL_FLAG})=0 AND (p.flags & {Post.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)
if not rotate_per_day:
q = f"""
SELECT
COUNT(p.id)
FROM posts p
JOIN subspaces s ON p.subspace=s.id
{filter}
WHERE {' AND '.join(cond)}
"""
else:
q = f"""
SELECT
COUNT(*)
FROM (
SELECT
p.subspace, DATE(p.ts_created)
FROM posts p
JOIN subspaces s ON p.subspace=s.id
{filter}
WHERE {' AND '.join(cond)}
GROUP BY p.subspace, DATE(p.ts_created)
) AS per_day_counts
"""
cur = self.conn.cursor()
cur.execute(q, values)
for (count,) in cur:
return count
return 0
def unset_post_omit_flags(self, user: User):
# This is used when a Limited user is elevated to a Basic role.
# The forced omit flags are cleared.
cur = self.conn.cursor()
cur.execute("UPDATE posts SET flags=0 WHERE user=?", (user.id,))
self.commit()
def batch_delete_limited_users(self, max_age, max_post_count=1):
for user in self.get_users(role=User.LIMITED, max_age=max_age, avatar='🚀',
empty_info=True,
max_post_count=max_post_count):
self.destroy_user(user)
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)
post.subspace = 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)
self.unpublish_if_empty(post)
def move_comments(self, post: Post, oldsub_id, newsub_id):
cur = self.conn.cursor()
cur.execute("UPDATE posts SET subspace=? WHERE subspace=? AND parent=?",
(newsub_id, oldsub_id, post.id))
self.commit()
def notify_mentioned(self, post, content, old_content=None):
names = parse_at_names(content)
post_user = self.get_user(id=post.user)
if post_user.role == User.LIMITED:
# Limited users can only mention @admin.
names = list(filter(lambda n: n == 'admin', names))
if old_content:
# Only notify newly added @-names.
for old in parse_at_names(old_content):
if old in names:
names.remove(old)
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, comment)
SELECT
{Notification.MENTION}, id, {post.user}, {notif_post_id}, {post.id if post.parent else "NULL"}
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, comment)
VALUES (?, ?, ?, ?, ?)
""", (Notification.COMMENT_ON_COMMENTED, uid, new_comment.user, new_comment.parent,
new_comment.id))
if uids:
self.commit()
def notify_followers(self, actor: User, post_id, notif_type, follow_type, target_id,
comment_id=None):
cur = self.conn.cursor()
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src, post, comment)
SELECT
{notif_type},
user,
{actor.id},
{post_id},
?
FROM follow
WHERE type={follow_type} AND target={target_id}
""", (comment_id,))
self.commit()
def notify_moderators(self, notif_type, user:User, post:Post=None, subspace:Subspace=None):
if notif_type == Notification.REPORT and post.user == user.id:
# No self-reporting.
return
cur = self.conn.cursor()
# Always notify the administrator.
if user.id != 1:
cur.execute("""
INSERT IGNORE INTO notifs (type, dst, src, post)
VALUES (?, 1, ?, ?)
""", (notif_type, user.id, post.id if post else None))
if not subspace and post:
subspace = self.get_subspace(id=post.subspace)
if subspace:
# Notify each moderator of the post's subspace.
cur.execute("""
INSERT IGNORE INTO notifs (type, dst, src, post)
SELECT ?, user, ?, ?
FROM mods
WHERE subspace=? AND NOT user IN (?, ?, ?)
""", (notif_type, user.id, post.id if post else None,
subspace.id,
user.id, post.user if post else user.id, subspace.owner))
self.commit()
def notify_reminder(self, user: User, post: Post):
# Enable the notification automatically. Old users may not have the flag set.
if not (user.notif & Notification.REMINDER):
self.update_user(user, notif=user.notif | Notification.REMINDER)
cur = self.conn.cursor()
cur.execute("""
INSERT IGNORE INTO notifs (type, dst, src, post)
VALUES (?, ?, 1, ?)
""", (Notification.REMINDER, user.id, post.id))
self.commit()
def notify_thanks(self, user: User, post: Post):
cur = self.conn.cursor()
ntype = Notification.THANKS
cur.execute(f"""
IF (SELECT COUNT(id)=0
FROM notifs
WHERE is_hidden=FALSE AND type={ntype} AND src=? AND dst=? AND post=?)
THEN
INSERT IGNORE INTO notifs (type, src, dst, post) VALUES ({ntype}, ?, ?, ?);
END IF
""", (user.id, post.user, post.id,
user.id, post.user, post.id))
self.update_user(user, active=True)
self.commit()
def notify_new_poll(self, post: Post):
# If this subspace or post is omitted from All Posts, don't notify anyone.
if post.flags & Post.OMIT_FROM_ALL_FLAG:
return
subspace = self.get_subspace(id=post.subspace)
if subspace.flags & Subspace.OMIT_FROM_ALL_FLAG:
return
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 notify_admin(self, notif_type, user_id, subspace_id=None):
cur = self.conn.cursor()
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src, subspace)
VALUES (?, 1, ?, ?)
""", (notif_type, user_id, subspace_id))
self.commit()
def notify_role(self, user: User):
cur = self.conn.cursor()
cur.execute(f"""
INSERT IGNORE INTO notifs (type, dst, src)
VALUES (?, ?, 1)
""", (Notification.ROLE_CHANGED, user.id))
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 modify_reactions(self, user: User, post: Post, reaction: str):
"""If `reaction` is None, it existing reaction will be removed."""
cur = self.conn.cursor()
cur.execute("DELETE FROM reactions WHERE post=? AND user=?", (post.id, user.id))
if reaction:
cur.execute("INSERT IGNORE INTO reactions (post, user, reaction) VALUES (?, ?, ?)",
(post.id, user.id, reaction))
cur.execute("INSERT IGNORE INTO notifs (type, src, dst, post) VALUES (?, ?, ?, ?)",
(Notification.REACTION, user.id, post.user, post.id))
self.update_user(user, active=True)
self.commit()
def get_reactions(self, post, user_mutes=set()) -> dict:
"""Returns a dictionary of {reactions: count}."""
cur = self.conn.cursor()
cur.execute("SELECT reaction, user FROM reactions WHERE post=?", (post.id,))
counts = {}
for (r, user_id) in cur:
if (MUTE_USER, user_id) in user_mutes:
continue
if r not in counts:
counts[r] = 1
else:
counts[r] += 1
return counts
def get_user_reaction(self, post: Post, user_id: int):
cur = self.conn.cursor()
cur.execute("SELECT reaction FROM reactions WHERE post=? AND user=?", (post.id, user_id))
for (r,) in cur: return r
return None
def get_likes(self, post, user_mutes=set()):
cur = self.conn.cursor()
cur.execute("""
SELECT u.name, u.id
FROM likes
JOIN users u ON likes.user=u.id
WHERE likes.post=?
""", (post.id,))
users = []
for (name, liker_id) in cur:
if not (MUTE_USER, liker_id) in user_mutes:
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()
subspace_cond = ''
if not subspace:
# Issue tracker tags should not be included in the All Posts tags.
subspace_cond = f' AND (s.flags & {Subspace.ISSUE_TRACKER})=0'
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 {subspace_cond}
{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')
cond.append('mt.user IS NULL')
cur = self.conn.cursor()
cur.execute(f"""
SELECT
n.id, n.type, n.dst, n.src, n.post, n.subspace, n.comment, n.is_sent, UNIX_TIMESTAMP(n.ts),
u.name,
p.title, p.issueid, p.summary,
s.id, s.name, s.owner,
s2.name,
r.reaction
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)
LEFT JOIN reactions r ON r.post=n.post AND n.src=r.user
LEFT JOIN mute mt ON mt.user={user.id} AND (
(mt.type={MUTE_USER} AND mt.target=n.src) OR
(mt.type={MUTE_SUBSPACE} AND mt.target=p.subspace AND p.user!={user.id} AND n.type!={Notification.MENTION}) OR
(mt.type={MUTE_POST} AND mt.target=n.post AND n.type!={Notification.MENTION})
)
WHERE {' AND '.join(cond)}
""", tuple(values))
notifs = []
for (id, type, dst, src, post, subspace, comment, is_sent, ts, src_name, post_title, post_issueid,
post_summary, post_subid, post_subname, post_subowner, subname, reaction) in cur:
notifs.append(Notification(id, type, dst, src, post, subspace, comment, is_sent, ts,
src_name, post_title, post_issueid, post_summary,
post_subname, post_subowner, subname, reaction,
post_subid=post_subid))
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()
have_single = {} # Only one of some types are kept.
# Create a table of (post, src) -> [notifs].
indexed = {}
for notif in notifs:
# Some notifications are always kept as-is.
if (notif.type not in (Notification.SUBSPACE_INFO_UPDATED,
Notification.USER_FLAIR_CHANGED)) and (
not notif.post or notif.type in (Notification.ISSUE_CLOSED,
Notification.REMINDER,
Notification.REPORT)):
resolved.append(notif)
kept_ids.add(notif.id)
continue
key = (notif.post, notif.src)
# The priority value -1 means "keep only one".
if notif.type in Notification.PRIORITY and Notification.PRIORITY[notif.type] < 0:
if not notif.type in have_single:
have_single[notif.type] = {key}
elif key in have_single[notif.type]:
continue
else:
have_single[notif.type].add(key)
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 = -100
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
if top_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 expire_tokens(self):
cur = self.conn.cursor()
cur.execute("""
DELETE FROM tokens
WHERE TIMESTAMPDIFF(MINUTE, ts, CURRENT_TIMESTAMP())>=60
""")
self.commit()
def get_token(self, user: User):
self.expire_tokens()
user_id = user.id if user else 0
# 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):
self.expire_tokens()
cur = self.conn.cursor()
cur.execute("""
SELECT user
FROM tokens
WHERE user=? AND token=? COLLATE utf8mb4_bin
""", (user.id if user else 0, 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
def get_access_rate(self, window_seconds, from_addr, entry_type):
cond = ['type=?']
values = [entry_type]
if from_addr != None:
cond.append('remote=?')
values.append(address_hash(from_addr))
cond.append('TIMESTAMPDIFF(SECOND, ts, CURRENT_TIMESTAMP())<?')
values.append(window_seconds)
cur = self.conn.cursor()
cur.execute(f"SELECT COUNT(*) FROM log WHERE {' AND '.join(cond)}", values)
for (rate,) in cur:
pass
cur.execute("DELETE FROM log WHERE TIMESTAMPDIFF(MINUTE, ts, CURRENT_TIMESTAMP())>=60")
self.commit()
return rate
def add_log_entry(self, from_addr, type):
cur = self.conn.cursor()
cur.execute("INSERT INTO log (remote, type) VALUES (?, ?)", (address_hash(from_addr), type))
self.commit()
def get_statistics(self):
cur = self.conn.cursor()
cur.execute("""SELECT
(SELECT COUNT(id) FROM users) AS total,
(SELECT COUNT(id) FROM users WHERE TIMESTAMPDIFF(DAY, ts_active, CURRENT_TIMESTAMP()) <= 30) AS m_visited,
(SELECT COUNT(DISTINCT(user)) FROM posts WHERE TIMESTAMPDIFF(DAY, ts_created, CURRENT_TIMESTAMP()) <= 30) AS m_post_cmt,
(SELECT COUNT(DISTINCT(user)) FROM posts WHERE parent=0) AS posters,
(SELECT COUNT(DISTINCT(user)) FROM posts WHERE parent!=0) AS commenters
""")
for (total, m_visited, m_post_cmt, posters, commenters) in cur:
return {
'total': total,
'posters': posters,
'commenters': commenters,
'm_visited': m_visited,
'm_post_cmt': m_post_cmt
}
return {}
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
p_terms = []
tag_terms = []
for term in terms:
if term[0] == '#':
tag_terms.append(term[1:])
else:
p_terms.append(f'%{term}%')
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 and p_terms:
ulimit = int(limit / 2)
cond = ['(name LIKE ? OR info LIKE ? OR url LIKE ?)'] * len(p_terms)
values = []
for pt in p_terms:
values += [pt, pt, pt]
cur.execute(f"""
SELECT UNIX_TIMESTAMP(ts_active), id, name, avatar, info, flair, url
FROM users
WHERE {' AND '.join(cond)}
ORDER BY ts_active DESC
LIMIT {ulimit}
OFFSET {ulimit * page_index}
""", values)
for (ts, id, name, avatar, info, flair, url) in cur:
self.results.append(((exact_match(name), ts),
User(id, name, info, flair, url, None, avatar, None, None, None,
None, None, None, None, None, ts, None, None, None, None)))
# Subspaces.
cur.execute(f"""
SELECT UNIX_TIMESTAMP(ts_active), id, name, info, url
FROM subspaces
WHERE {' AND '.join(['owner=0'] + 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 = ['is_draft=FALSE']
values = []
if subspace:
cond.append('subspace=' + str(subspace.id))
for pt in p_terms:
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]
for tt in tag_terms:
cond.append('t.tag=?')
values += [tt]
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
LEFT JOIN tags t ON t.post=p.id
WHERE {' 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)