mirror of
https://git.skyjake.fi/gemini/bubble.git
synced 2024-06-30 04:27:06 +00:00
2066 lines
77 KiB
Python
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)
|