415 lines
14 KiB
Python
415 lines
14 KiB
Python
from datetime import timedelta, datetime, timezone
|
|
|
|
from app import db
|
|
import secrets
|
|
from libforget.interval import decompose_interval
|
|
import random
|
|
from sqlalchemy.ext.declarative import declared_attr
|
|
|
|
|
|
class TimestampMixin(object):
|
|
created_at = db.Column(
|
|
db.DateTime(timezone=True), server_default=db.func.now(),
|
|
nullable=False)
|
|
updated_at = db.Column(
|
|
db.DateTime(timezone=True), server_default=db.func.now(),
|
|
onupdate=db.func.now(), nullable=False)
|
|
|
|
def touch(self):
|
|
self.updated_at = db.func.now()
|
|
|
|
|
|
class RemoteIDMixin(object):
|
|
@property
|
|
def service(self):
|
|
if not self.id:
|
|
return None
|
|
return self.id.split(":")[0]
|
|
|
|
@property
|
|
def twitter_id(self):
|
|
if not self.id:
|
|
return None
|
|
if self.service != "twitter":
|
|
raise Exception(
|
|
"tried to get twitter id for a {} {}"
|
|
.format(self.service, type(self)))
|
|
return self.id.split(":")[1]
|
|
|
|
@twitter_id.setter
|
|
def twitter_id(self, id_):
|
|
self.id = "twitter:{}".format(id_)
|
|
|
|
@property
|
|
def mastodon_instance(self):
|
|
if not self.id:
|
|
return None
|
|
if self.service != "mastodon":
|
|
raise Exception(
|
|
"tried to get mastodon instance for a {} {}"
|
|
.format(self.service, type(self)))
|
|
return self.id.split(":", 1)[1].split('@')[1]
|
|
|
|
@mastodon_instance.setter
|
|
def mastodon_instance(self, instance):
|
|
self.id = "mastodon:{}@{}".format(self.mastodon_id, instance)
|
|
|
|
@property
|
|
def mastodon_id(self):
|
|
if not self.id:
|
|
return None
|
|
if self.service != "mastodon":
|
|
raise Exception(
|
|
"tried to get mastodon id for a {} {}"
|
|
.format(self.service, type(self)))
|
|
return self.id.split(":", 1)[1].split('@')[0]
|
|
|
|
@mastodon_id.setter
|
|
def mastodon_id(self, id_):
|
|
self.id = "mastodon:{}@{}".format(id_, self.mastodon_instance)
|
|
|
|
@property
|
|
def misskey_instance(self):
|
|
if not self.id:
|
|
return None
|
|
if self.service != "misskey":
|
|
raise Exception(
|
|
"tried to get misskey instance for a {} {}"
|
|
.format(self.service, type(self)))
|
|
return self.id.split(":", 1)[1].split('@')[1]
|
|
|
|
@misskey_instance.setter
|
|
def misskey_instance(self, instance):
|
|
self.id = "misskey:{}@{}".format(self.misskey_id, instance)
|
|
|
|
@property
|
|
def misskey_id(self):
|
|
if not self.id:
|
|
return None
|
|
if self.service != "misskey":
|
|
raise Exception(
|
|
"tried to get misskey id for a {} {}"
|
|
.format(self.service, type(self)))
|
|
return self.id.split(":", 1)[1].split('@')[0]
|
|
|
|
@misskey_id.setter
|
|
def misskey_id(self, id_):
|
|
self.id = "misskey:{}@{}".format(id_, self.misskey_instance)
|
|
|
|
@property
|
|
def remote_id(self):
|
|
if self.service == 'twitter':
|
|
return self.twitter_id
|
|
elif self.service == 'mastodon':
|
|
return self.mastodon_id
|
|
elif self.service == 'misskey':
|
|
return self.misskey_id
|
|
|
|
|
|
ThreeWayPolicyEnum = db.Enum('keeponly', 'deleteonly', 'none',
|
|
name='enum_3way_policy')
|
|
|
|
|
|
@decompose_interval('policy_delete_every')
|
|
@decompose_interval('policy_keep_younger')
|
|
class Account(TimestampMixin, RemoteIDMixin):
|
|
__tablename__ = 'accounts'
|
|
id = db.Column(db.String, primary_key=True)
|
|
|
|
policy_enabled = db.Column(db.Boolean, server_default='FALSE',
|
|
nullable=False)
|
|
policy_keep_latest = db.Column(db.Integer, server_default='100',
|
|
nullable=False)
|
|
policy_keep_favourites = db.Column(ThreeWayPolicyEnum,
|
|
server_default='none', nullable=False)
|
|
policy_keep_media = db.Column(ThreeWayPolicyEnum, server_default='none',
|
|
nullable=False)
|
|
policy_delete_every = db.Column(db.Interval, server_default='30 minutes',
|
|
nullable=False)
|
|
policy_keep_younger = db.Column(db.Interval, server_default='365 days',
|
|
nullable=False)
|
|
policy_keep_direct = db.Column(db.Boolean, server_default='TRUE',
|
|
nullable=False)
|
|
|
|
display_name = db.Column(db.String)
|
|
screen_name = db.Column(db.String)
|
|
avatar_url = db.Column(db.String)
|
|
reported_post_count = db.Column(db.Integer)
|
|
|
|
last_fetch = db.Column(db.DateTime(timezone=True),
|
|
server_default='epoch', index=True)
|
|
last_refresh = db.Column(db.DateTime(timezone=True),
|
|
server_default='epoch', index=True)
|
|
last_delete = db.Column(db.DateTime(timezone=True), index=True)
|
|
next_delete = db.Column(db.DateTime(timezone=True), index=True)
|
|
|
|
fetch_history_complete = db.Column(db.Boolean, server_default='FALSE',
|
|
nullable=False)
|
|
|
|
fetch_current_batch_end_id = db.Column(db.String)
|
|
fetch_current_batch_end_date = db.Column(db.DateTime(timezone=True))
|
|
|
|
reason = db.Column(db.String)
|
|
dormant = db.Column(db.Boolean, server_default='FALSE', nullable=False)
|
|
|
|
backoff_level = db.Column(db.Integer, server_default='0', nullable=False)
|
|
backoff_until = db.Column(db.DateTime(timezone=True), server_default='now', nullable=False)
|
|
BACKOFF_MAX = 14
|
|
# backoff is 10 seconds * 2^backoff_level
|
|
# this gives us roughly 1.8 days at level 14
|
|
|
|
def touch_fetch(self):
|
|
self.last_fetch = db.func.now()
|
|
|
|
def touch_delete(self):
|
|
self.last_delete = db.func.now()
|
|
# if it's been more than 1 delete cycle ago that we've deleted a post,
|
|
# reset next_delete to be 1 cycle away
|
|
if (datetime.now(timezone.utc) - self.next_delete
|
|
> self.policy_delete_every):
|
|
self.next_delete = db.func.now() + self.policy_delete_every
|
|
else:
|
|
self.next_delete += self.policy_delete_every
|
|
|
|
def touch_refresh(self):
|
|
self.last_refresh = db.func.now()
|
|
|
|
def get_avatar(self):
|
|
from app import imgproxy
|
|
from flask import url_for
|
|
return url_for('avatar', identifier=imgproxy.identifier_for(self.avatar_url))
|
|
|
|
@db.validates('policy_keep_younger', 'policy_delete_every')
|
|
def validate_intervals(self, key, value):
|
|
if not (value == timedelta(0) or value >= timedelta(minutes=1)):
|
|
value = timedelta(minutes=1)
|
|
if key == 'policy_delete_every' and \
|
|
self.next_delete and\
|
|
datetime.now(timezone.utc) + value < self.next_delete:
|
|
# make sure that next delete is not in the far future
|
|
self.next_delete = datetime.now(timezone.utc) + value
|
|
return value
|
|
|
|
# pylint: disable=R0201
|
|
@db.validates('policy_keep_latest')
|
|
def validate_empty_string_is_zero(self, key, value):
|
|
if isinstance(value, str) and value.strip() == '':
|
|
return 0
|
|
return value
|
|
|
|
@db.validates('policy_enabled')
|
|
def on_policy_enable(self, key, enable):
|
|
if not self.policy_enabled and enable:
|
|
self.next_delete = (
|
|
datetime.now(timezone.utc) + self.policy_delete_every)
|
|
self.reason = None
|
|
self.dormant = False
|
|
return enable
|
|
|
|
@db.validates('policy_keep_direct')
|
|
def validate_bool_accept_string(self, key, value):
|
|
if isinstance(value, str):
|
|
return value.lower() == 'true'
|
|
return value
|
|
|
|
|
|
# backref: tokens
|
|
# backref: twitter_archives
|
|
# backref: posts
|
|
# backref: sessions
|
|
|
|
def post_count(self):
|
|
return Post.query.with_parent(self, 'posts').count()
|
|
|
|
def estimate_eligible_for_delete(self):
|
|
"""
|
|
this is an estimation because we do not know if favourite status has
|
|
changed since last time a post was refreshed and it is unfeasible to
|
|
refresh every single post every time we need to know how many posts are
|
|
eligible to delete
|
|
"""
|
|
latest_n_posts = (Post.query.with_parent(self, 'posts')
|
|
.order_by(db.desc(Post.created_at))
|
|
.limit(self.policy_keep_latest))
|
|
query = (Post.query.with_parent(self, 'posts')
|
|
.filter(Post.created_at <=
|
|
db.func.now() - self.policy_keep_younger)
|
|
.except_(latest_n_posts))
|
|
if(self.policy_keep_favourites != 'none'):
|
|
query = query.filter(db.or_(
|
|
Post.favourite == (self.policy_keep_favourites == 'deleteonly'),
|
|
Post.is_reblog))
|
|
if(self.policy_keep_media != 'none'):
|
|
query = query.filter(db.or_(
|
|
Post.has_media == (self.policy_keep_media == 'deleteonly'),
|
|
Post.is_reblog))
|
|
if(self.policy_keep_direct):
|
|
query = query.filter(~Post.direct)
|
|
return query.count()
|
|
|
|
def force_log_out(self):
|
|
Session.query.with_parent(self).delete()
|
|
db.session.commit()
|
|
|
|
def backoff(self):
|
|
self.backoff_level = min(self.backoff_level + 1, self.BACKOFF_MAX)
|
|
backoff_for = 10 * 2 ** self.backoff_level
|
|
backoff_for *= random.uniform(1, 1.3)
|
|
self.backoff_until = datetime.utcnow() + timedelta(seconds=backoff_for)
|
|
|
|
def reset_backoff(self):
|
|
self.backoff_until = datetime.utcnow()
|
|
self.backoff_level = 0
|
|
|
|
|
|
class Account(Account, db.Model):
|
|
def __str__(self):
|
|
return f"<Account({self.id}, {self.screen_name}, {self.display_name})>"
|
|
|
|
|
|
class OAuthToken(db.Model, TimestampMixin):
|
|
__tablename__ = 'oauth_tokens'
|
|
|
|
token = db.Column(db.String, primary_key=True)
|
|
token_secret = db.Column(db.String, nullable=True)
|
|
|
|
account_id = db.Column(db.String,
|
|
db.ForeignKey('accounts.id', ondelete='CASCADE',
|
|
onupdate='CASCADE'),
|
|
nullable=True, index=True)
|
|
account = db.relationship(
|
|
Account,
|
|
backref=db.backref('tokens',
|
|
order_by=lambda: db.desc(OAuthToken.created_at))
|
|
)
|
|
|
|
# note: account_id is nullable here because we don't know what account a
|
|
# token is for until we call /account/verify_credentials with it
|
|
|
|
|
|
class Session(db.Model, TimestampMixin):
|
|
__tablename__ = 'sessions'
|
|
|
|
id = db.Column(db.String, primary_key=True,
|
|
default=secrets.token_urlsafe)
|
|
|
|
account_id = db.Column(
|
|
db.String,
|
|
db.ForeignKey('accounts.id',
|
|
ondelete='CASCADE', onupdate='CASCADE'),
|
|
nullable=False, index=True)
|
|
account = db.relationship(Account, lazy='joined', backref='sessions')
|
|
|
|
csrf_token = db.Column(db.String,
|
|
default=secrets.token_urlsafe,
|
|
nullable=False)
|
|
|
|
|
|
class Post(db.Model, TimestampMixin, RemoteIDMixin):
|
|
__tablename__ = 'posts'
|
|
|
|
id = db.Column(db.String, primary_key=True)
|
|
|
|
author_id = db.Column(
|
|
db.String,
|
|
db.ForeignKey('accounts.id',
|
|
ondelete='CASCADE', onupdate='CASCADE'),
|
|
nullable=False)
|
|
author = db.relationship(
|
|
Account,
|
|
foreign_keys = (author_id,),
|
|
backref=db.backref('posts',
|
|
order_by=lambda: db.desc(Post.created_at)))
|
|
|
|
favourite = db.Column(db.Boolean, server_default='FALSE', nullable=False)
|
|
has_media = db.Column(db.Boolean, server_default='FALSE', nullable=False)
|
|
direct = db.Column(db.Boolean, server_default='FALSE', nullable=False)
|
|
|
|
is_reblog = db.Column(db.Boolean, server_default='FALSE', nullable=False)
|
|
|
|
def __str__(self):
|
|
return '<Post ({}, Author: {})>'.format(self.id, self.author_id)
|
|
|
|
|
|
db.Index('ix_posts_author_id_created_at', Post.author_id, Post.created_at)
|
|
|
|
|
|
class TwitterArchive(db.Model, TimestampMixin):
|
|
__tablename__ = 'twitter_archives'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
account_id = db.Column(
|
|
db.String,
|
|
db.ForeignKey('accounts.id',
|
|
onupdate='CASCADE', ondelete='CASCADE'),
|
|
nullable=False)
|
|
account = db.relationship(
|
|
Account,
|
|
backref=db.backref('twitter_archives',
|
|
order_by=lambda: db.desc(TwitterArchive.id)))
|
|
body = db.deferred(db.Column(db.LargeBinary, nullable=False))
|
|
chunks = db.Column(db.Integer)
|
|
chunks_successful = db.Column(db.Integer,
|
|
server_default='0', nullable=False)
|
|
chunks_failed = db.Column(db.Integer, server_default='0', nullable=False)
|
|
|
|
def status(self):
|
|
if self.chunks is None or self.chunks_failed > 0:
|
|
return 'failed'
|
|
if self.chunks_successful == self.chunks:
|
|
return 'successful'
|
|
return 'pending'
|
|
|
|
|
|
ProtoEnum = db.Enum('http', 'https', name='enum_protocol')
|
|
|
|
|
|
class MastodonApp(db.Model, TimestampMixin):
|
|
__tablename__ = 'mastodon_apps'
|
|
|
|
instance = db.Column(db.String, primary_key=True)
|
|
client_id = db.Column(db.String, nullable=False)
|
|
client_secret = db.Column(db.String, nullable=False)
|
|
protocol = db.Column(ProtoEnum, nullable=False)
|
|
|
|
|
|
class MastodonInstance(db.Model):
|
|
"""
|
|
this is for the autocomplete in the mastodon login form
|
|
|
|
it isn't coupled with anything else so that we can seed it with
|
|
some popular instances ahead of time
|
|
"""
|
|
__tablename__ = 'mastodon_instances'
|
|
|
|
instance = db.Column(db.String, primary_key=True)
|
|
popularity = db.Column(db.Float, server_default='10', nullable=False)
|
|
|
|
def bump(self, value=1):
|
|
self.popularity = (self.popularity or 10) + value
|
|
|
|
class MisskeyApp(db.Model, TimestampMixin):
|
|
__tablename__ = 'misskey_apps'
|
|
|
|
instance = db.Column(db.String, primary_key=True)
|
|
protocol = db.Column(db.String, nullable=False)
|
|
client_secret = db.Column(db.String, nullable=False)
|
|
|
|
class MisskeyInstance(db.Model):
|
|
"""
|
|
this is for the autocomplete in the misskey login form
|
|
it isn't coupled with anything else so that we can seed it with
|
|
some popular instances ahead of time
|
|
"""
|
|
__tablename__ = 'misskey_instances'
|
|
|
|
instance = db.Column(db.String, primary_key=True)
|
|
popularity = db.Column(db.Float, server_default='10', nullable=False)
|
|
|
|
def bump(self, value=1):
|
|
self.popularity = (self.popularity or 10) + value
|
|
|
|
class WorkerCheckin(db.Model, TimestampMixin):
|
|
__tablename__ = 'worker_checkins'
|
|
id = db.Column(db.Integer, primary_key=True)
|