relay/relay/manage.py

993 lines
25 KiB
Python

from __future__ import annotations
import aputils
import asyncio
import click
import json
import os
from pathlib import Path
from shutil import copyfile
from typing import Any
from urllib.parse import urlparse
from . import __version__
from . import http_client as http
from . import logger as logging
from .application import Application
from .compat import RelayConfig, RelayDatabase
from .database import RELAY_SOFTWARE, get_database, schema
from .misc import ACTOR_FORMATS, SOFTWARE, IS_DOCKER, Message
def check_alphanumeric(text: str) -> str:
if not text.isalnum():
raise click.BadParameter('String not alphanumeric')
return text
@click.group('cli', context_settings = {'show_default': True})
@click.option('--config', '-c', type = Path, help = 'path to the relay\'s config')
@click.version_option(version = __version__, prog_name = 'ActivityRelay')
@click.pass_context
def cli(ctx: click.Context, config: Path | None) -> None:
if IS_DOCKER:
config = Path("/data/relay.yaml")
# The database was named "relay.jsonld" even though it's an sqlite file. Fix it.
db = Path('/data/relay.sqlite3')
wrongdb = Path('/data/relay.jsonld')
if wrongdb.exists() and not db.exists():
try:
with wrongdb.open('rb') as fd:
json.load(fd)
except json.JSONDecodeError:
wrongdb.rename(db)
ctx.obj = Application(config)
@cli.command('setup')
@click.option('--skip-questions', '-s', is_flag = True, help = 'Just setup the database')
@click.pass_context
def cli_setup(ctx: click.Context, skip_questions: bool) -> None:
'Generate a new config and create the database'
if ctx.obj.signer is not None:
if not click.prompt('The database is already setup. Are you sure you want to continue?'):
return
if skip_questions and ctx.obj.config.domain.endswith('example.com'):
click.echo('You cannot skip the questions if the relay is not configured yet')
return
if not skip_questions:
while True:
ctx.obj.config.domain = click.prompt(
'What domain will the relay be hosted on?',
default = ctx.obj.config.domain
)
if not ctx.obj.config.domain.endswith('example.com'):
break
click.echo('The domain must not end with "example.com"')
if not IS_DOCKER:
ctx.obj.config.listen = click.prompt(
'Which address should the relay listen on?',
default = ctx.obj.config.listen
)
ctx.obj.config.port = click.prompt(
'What TCP port should the relay listen on?',
default = ctx.obj.config.port,
type = int
)
ctx.obj.config.db_type = click.prompt(
'Which database backend will be used?',
default = ctx.obj.config.db_type,
type = click.Choice(['postgres', 'sqlite'], case_sensitive = False)
)
if ctx.obj.config.db_type == 'sqlite' and not IS_DOCKER:
ctx.obj.config.sq_path = click.prompt(
'Where should the database be stored?',
default = ctx.obj.config.sq_path
)
elif ctx.obj.config.db_type == 'postgres':
ctx.obj.config.pg_name = click.prompt(
'What is the name of the database?',
default = ctx.obj.config.pg_name
)
ctx.obj.config.pg_host = click.prompt(
'What IP address, hostname, or unix socket does the server listen on?',
default = ctx.obj.config.pg_host,
type = int
)
ctx.obj.config.pg_port = click.prompt(
'What port does the server listen on?',
default = ctx.obj.config.pg_port,
type = int
)
ctx.obj.config.pg_user = click.prompt(
'Which user will authenticate with the server?',
default = ctx.obj.config.pg_user
)
ctx.obj.config.pg_pass = click.prompt(
'User password',
hide_input = True,
show_default = False,
default = ctx.obj.config.pg_pass or ""
) or None
ctx.obj.config.ca_type = click.prompt(
'Which caching backend?',
default = ctx.obj.config.ca_type,
type = click.Choice(['database', 'redis'], case_sensitive = False)
)
if ctx.obj.config.ca_type == 'redis':
ctx.obj.config.rd_host = click.prompt(
'What IP address, hostname, or unix socket does the server listen on?',
default = ctx.obj.config.rd_host
)
ctx.obj.config.rd_port = click.prompt(
'What port does the server listen on?',
default = ctx.obj.config.rd_port,
type = int
)
ctx.obj.config.rd_user = click.prompt(
'Which user will authenticate with the server',
default = ctx.obj.config.rd_user
)
ctx.obj.config.rd_pass = click.prompt(
'User password',
hide_input = True,
show_default = False,
default = ctx.obj.config.rd_pass or ""
) or None
ctx.obj.config.rd_database = click.prompt(
'Which database number to use?',
default = ctx.obj.config.rd_database,
type = int
)
ctx.obj.config.rd_prefix = click.prompt(
'What text should each cache key be prefixed with?',
default = ctx.obj.config.rd_database,
type = check_alphanumeric
)
ctx.obj.config.save()
config = {
'private-key': aputils.Signer.new('n/a').export()
}
with ctx.obj.database.session() as conn:
for key, value in config.items():
conn.put_config(key, value)
if IS_DOCKER:
click.echo("Relay all setup! Start the container to run the relay.")
return
if click.confirm('Relay all setup! Would you like to run it now?'):
cli_run.callback() # type: ignore
@cli.command('run')
@click.option('--dev', '-d', is_flag=True, help='Enable developer mode')
@click.pass_context
def cli_run(ctx: click.Context, dev: bool = False) -> None:
'Run the relay'
if ctx.obj.config.domain.endswith('example.com') or ctx.obj.signer is None:
if not IS_DOCKER:
click.echo('Relay is not set up. Please run "activityrelay setup".')
return
cli_setup.callback() # type: ignore
return
ctx.obj['dev'] = dev
ctx.obj.run()
# todo: figure out why the relay doesn't quit properly without this
os._exit(0)
@cli.command('db-maintenance')
@click.option('--fix-timestamps', '-t', is_flag = True,
help = 'Make sure timestamps in the database are float values')
@click.pass_context
def cli_db_maintenance(ctx: click.Context, fix_timestamps: bool) -> None:
'Perform maintenance tasks on the database'
if fix_timestamps:
with ctx.obj.database.session(True) as conn:
conn.fix_timestamps()
with ctx.obj.database.session(False) as conn:
with conn.execute("VACUUM"):
pass
@cli.command('convert')
@click.option('--old-config', '-o', help = 'Path to the config file to convert from')
@click.pass_context
def cli_convert(ctx: click.Context, old_config: str) -> None:
'Convert an old config and jsonld database to the new format.'
old_config = Path(old_config).expanduser().resolve() if old_config else ctx.obj.config.path
backup = ctx.obj.config.path.parent.joinpath(f'{ctx.obj.config.path.stem}.backup.yaml')
if str(old_config) == str(ctx.obj.config.path) and not backup.exists():
logging.info('Created backup config @ %s', backup)
copyfile(ctx.obj.config.path, backup)
config = RelayConfig(old_config)
config.load()
database = RelayDatabase(config)
database.load()
ctx.obj.config.set('listen', config['listen'])
ctx.obj.config.set('port', config['port'])
ctx.obj.config.set('workers', config['workers'])
ctx.obj.config.set('sq_path', config['db'].replace('jsonld', 'sqlite3'))
ctx.obj.config.set('domain', config['host'])
ctx.obj.config.save()
# fix: mypy complains about the types returned by click.progressbar when updating click to 8.1.7
with get_database(ctx.obj.config) as db:
with db.session(True) as conn:
conn.put_config('private-key', database['private-key'])
conn.put_config('note', config['note'])
conn.put_config('whitelist-enabled', config['whitelist_enabled'])
with click.progressbar(
database['relay-list'].values(),
label = 'Inboxes'.ljust(15),
width = 0
) as inboxes:
for inbox in inboxes:
if inbox['software'] in {'akkoma', 'pleroma'}:
actor = f'https://{inbox["domain"]}/relay'
elif inbox['software'] == 'mastodon':
actor = f'https://{inbox["domain"]}/actor'
else:
actor = None
conn.put_inbox(
inbox['domain'],
inbox['inbox'],
actor = actor,
followid = inbox['followid'],
software = inbox['software']
)
with click.progressbar(
config['blocked_software'],
label = 'Banned software'.ljust(15),
width = 0
) as banned_software:
for software in banned_software:
conn.put_software_ban(
software,
reason = 'relay' if software in RELAY_SOFTWARE else None
)
with click.progressbar(
config['blocked_instances'],
label = 'Banned domains'.ljust(15),
width = 0
) as banned_software:
for domain in banned_software:
conn.put_domain_ban(domain)
with click.progressbar(
config['whitelist'],
label = 'Whitelist'.ljust(15),
width = 0
) as whitelist:
for instance in whitelist:
conn.put_domain_whitelist(instance)
click.echo('Finished converting old config and database :3')
@cli.command('edit-config')
@click.option('--editor', '-e', help = 'Text editor to use')
@click.pass_context
def cli_editconfig(ctx: click.Context, editor: str) -> None:
'Edit the config file'
click.edit(
editor = editor,
filename = str(ctx.obj.config.path)
)
@cli.group('config')
def cli_config() -> None:
'Manage the relay settings stored in the database'
@cli_config.command('list')
@click.pass_context
def cli_config_list(ctx: click.Context) -> None:
'List the current relay config'
click.echo('Relay Config:')
with ctx.obj.database.session() as conn:
config = conn.get_config_all()
for key, value in config.to_dict().items():
if key in type(config).SYSTEM_KEYS():
continue
if key == 'log-level':
value = value.name
key_str = f'{key}:'.ljust(20)
click.echo(f'- {key_str} {repr(value)}')
@cli_config.command('set')
@click.argument('key')
@click.argument('value')
@click.pass_context
def cli_config_set(ctx: click.Context, key: str, value: Any) -> None:
'Set a config value'
try:
with ctx.obj.database.session() as conn:
new_value = conn.put_config(key, value)
except:
click.echo('Invalid config name:', key)
return
click.echo(f'{key}: {repr(new_value)}')
@cli.group('user')
def cli_user() -> None:
'Manage local users'
@cli_user.command('list')
@click.pass_context
def cli_user_list(ctx: click.Context) -> None:
'List all local users'
click.echo('Users:')
with ctx.obj.database.session() as conn:
for row in conn.get_users():
click.echo(f'- {row.username}')
@cli_user.command('create')
@click.argument('username')
@click.argument('handle', required = False)
@click.pass_context
def cli_user_create(ctx: click.Context, username: str, handle: str) -> None:
'Create a new local user'
with ctx.obj.database.session() as conn:
if conn.get_user(username) is not None:
click.echo(f'User already exists: {username}')
return
while True:
if not (password := click.prompt('New password', hide_input = True)):
click.echo('No password provided')
continue
if password != click.prompt('New password again', hide_input = True):
click.echo('Passwords do not match')
continue
break
conn.put_user(username, password, handle)
click.echo(f'Created user "{username}"')
@cli_user.command('delete')
@click.argument('username')
@click.pass_context
def cli_user_delete(ctx: click.Context, username: str) -> None:
'Delete a local user'
with ctx.obj.database.session() as conn:
if conn.get_user(username) is None:
click.echo(f'User does not exist: {username}')
return
conn.del_user(username)
click.echo(f'Deleted user "{username}"')
@cli_user.command('list-tokens')
@click.argument('username')
@click.pass_context
def cli_user_list_tokens(ctx: click.Context, username: str) -> None:
'List all API tokens for a user'
click.echo(f'Tokens for "{username}":')
with ctx.obj.database.session() as conn:
for row in conn.get_tokens(username):
click.echo(f'- {row.code}')
@cli_user.command('create-token')
@click.argument('username')
@click.pass_context
def cli_user_create_token(ctx: click.Context, username: str) -> None:
'Create a new API token for a user'
with ctx.obj.database.session() as conn:
if (user := conn.get_user(username)) is None:
click.echo(f'User does not exist: {username}')
return
token = conn.put_token(user.username)
click.echo(f'New token for "{username}": {token.code}')
@cli_user.command('delete-token')
@click.argument('code')
@click.pass_context
def cli_user_delete_token(ctx: click.Context, code: str) -> None:
'Delete an API token'
with ctx.obj.database.session() as conn:
if conn.get_token(code) is None:
click.echo('Token does not exist')
return
conn.del_token(code)
click.echo('Deleted token')
@cli.group('inbox')
def cli_inbox() -> None:
'Manage the inboxes in the database'
@cli_inbox.command('list')
@click.pass_context
def cli_inbox_list(ctx: click.Context) -> None:
'List the connected instances or relays'
click.echo('Connected to the following instances or relays:')
with ctx.obj.database.session() as conn:
for row in conn.get_inboxes():
click.echo(f'- {row.inbox}')
@cli_inbox.command('follow')
@click.argument('actor')
@click.pass_context
def cli_inbox_follow(ctx: click.Context, actor: str) -> None:
'Follow an actor (Relay must be running)'
instance: schema.Instance | None = None
with ctx.obj.database.session() as conn:
if conn.get_domain_ban(actor):
click.echo(f'Error: Refusing to follow banned actor: {actor}')
return
if (instance := conn.get_inbox(actor)) is not None:
inbox = instance.inbox
else:
if not actor.startswith('http'):
actor = f'https://{actor}/actor'
if (actor_data := asyncio.run(http.get(actor, sign_headers = True))) is None:
click.echo(f'Failed to fetch actor: {actor}')
return
inbox = actor_data.shared_inbox
message = Message.new_follow(
host = ctx.obj.config.domain,
actor = actor
)
asyncio.run(http.post(inbox, message, instance))
click.echo(f'Sent follow message to actor: {actor}')
@cli_inbox.command('unfollow')
@click.argument('actor')
@click.pass_context
def cli_inbox_unfollow(ctx: click.Context, actor: str) -> None:
'Unfollow an actor (Relay must be running)'
instance: schema.Instance | None = None
with ctx.obj.database.session() as conn:
if conn.get_domain_ban(actor):
click.echo(f'Error: Refusing to follow banned actor: {actor}')
return
if (instance := conn.get_inbox(actor)):
inbox = instance.inbox
message = Message.new_unfollow(
host = ctx.obj.config.domain,
actor = actor,
follow = instance.followid
)
else:
if not actor.startswith('http'):
actor = f'https://{actor}/actor'
actor_data = asyncio.run(http.get(actor, sign_headers = True))
if not actor_data:
click.echo("Failed to fetch actor")
return
inbox = actor_data.shared_inbox
message = Message.new_unfollow(
host = ctx.obj.config.domain,
actor = actor,
follow = {
'type': 'Follow',
'object': actor,
'actor': f'https://{ctx.obj.config.domain}/actor'
}
)
asyncio.run(http.post(inbox, message, instance))
click.echo(f'Sent unfollow message to: {actor}')
@cli_inbox.command('add')
@click.argument('inbox')
@click.option('--actor', '-a', help = 'Actor url for the inbox')
@click.option('--followid', '-f', help = 'Url for the follow activity')
@click.option('--software', '-s',
type = click.Choice(SOFTWARE),
help = 'Nodeinfo software name of the instance'
) # noqa: E124
@click.pass_context
def cli_inbox_add(
ctx: click.Context,
inbox: str,
actor: str | None = None,
followid: str | None = None,
software: str | None = None) -> None:
'Add an inbox to the database'
if not inbox.startswith('http'):
domain = inbox
inbox = f'https://{inbox}/inbox'
else:
domain = urlparse(inbox).netloc
if not software:
if (nodeinfo := asyncio.run(http.fetch_nodeinfo(domain))):
software = nodeinfo.sw_name
if not actor and software:
try:
actor = ACTOR_FORMATS[software].format(domain = domain)
except KeyError:
pass
with ctx.obj.database.session() as conn:
if conn.get_domain_ban(domain):
click.echo(f'Refusing to add banned inbox: {inbox}')
return
if conn.get_inbox(inbox):
click.echo(f'Error: Inbox already in database: {inbox}')
return
conn.put_inbox(domain, inbox, actor, followid, software)
click.echo(f'Added inbox to the database: {inbox}')
@cli_inbox.command('remove')
@click.argument('inbox')
@click.pass_context
def cli_inbox_remove(ctx: click.Context, inbox: str) -> None:
'Remove an inbox from the database'
with ctx.obj.database.session() as conn:
if not conn.del_inbox(inbox):
click.echo(f'Inbox not in database: {inbox}')
return
click.echo(f'Removed inbox from the database: {inbox}')
@cli.group('request')
def cli_request() -> None:
'Manage follow requests'
@cli_request.command('list')
@click.pass_context
def cli_request_list(ctx: click.Context) -> None:
'List all current follow requests'
click.echo('Follow requests:')
with ctx.obj.database.session() as conn:
for row in conn.get_requests():
date = row.created.strftime('%Y-%m-%d')
click.echo(f'- [{date}] {row.domain}')
@cli_request.command('accept')
@click.argument('domain')
@click.pass_context
def cli_request_accept(ctx: click.Context, domain: str) -> None:
'Accept a follow request'
try:
with ctx.obj.database.session() as conn:
instance = conn.put_request_response(domain, True)
except KeyError:
click.echo('Request not found')
return
message = Message.new_response(
host = ctx.obj.config.domain,
actor = instance.actor,
followid = instance.followid,
accept = True
)
asyncio.run(http.post(instance.inbox, message, instance))
if instance.software != 'mastodon':
message = Message.new_follow(
host = ctx.obj.config.domain,
actor = instance.actor
)
asyncio.run(http.post(instance.inbox, message, instance))
@cli_request.command('deny')
@click.argument('domain')
@click.pass_context
def cli_request_deny(ctx: click.Context, domain: str) -> None:
'Accept a follow request'
try:
with ctx.obj.database.session() as conn:
instance = conn.put_request_response(domain, False)
except KeyError:
click.echo('Request not found')
return
response = Message.new_response(
host = ctx.obj.config.domain,
actor = instance.actor,
followid = instance.followid,
accept = False
)
asyncio.run(http.post(instance.inbox, response, instance))
@cli.group('instance')
def cli_instance() -> None:
'Manage instance bans'
@cli_instance.command('list')
@click.pass_context
def cli_instance_list(ctx: click.Context) -> None:
'List all banned instances'
click.echo('Banned domains:')
with ctx.obj.database.session() as conn:
for row in conn.get_domain_bans():
if row.reason is not None:
click.echo(f'- {row.domain} ({row.reason})')
else:
click.echo(f'- {row.domain}')
@cli_instance.command('ban')
@click.argument('domain')
@click.option('--reason', '-r', help = 'Public note about why the domain is banned')
@click.option('--note', '-n', help = 'Internal note that will only be seen by admins and mods')
@click.pass_context
def cli_instance_ban(ctx: click.Context, domain: str, reason: str, note: str) -> None:
'Ban an instance and remove the associated inbox if it exists'
with ctx.obj.database.session() as conn:
if conn.get_domain_ban(domain) is not None:
click.echo(f'Domain already banned: {domain}')
return
conn.put_domain_ban(domain, reason, note)
conn.del_inbox(domain)
click.echo(f'Banned instance: {domain}')
@cli_instance.command('unban')
@click.argument('domain')
@click.pass_context
def cli_instance_unban(ctx: click.Context, domain: str) -> None:
'Unban an instance'
with ctx.obj.database.session() as conn:
if conn.del_domain_ban(domain) is None:
click.echo(f'Instance wasn\'t banned: {domain}')
return
click.echo(f'Unbanned instance: {domain}')
@cli_instance.command('update')
@click.argument('domain')
@click.option('--reason', '-r')
@click.option('--note', '-n')
@click.pass_context
def cli_instance_update(ctx: click.Context, domain: str, reason: str, note: str) -> None:
'Update the public reason or internal note for a domain ban'
if not (reason or note):
ctx.fail('Must pass --reason or --note')
with ctx.obj.database.session() as conn:
if not (row := conn.update_domain_ban(domain, reason, note)):
click.echo(f'Failed to update domain ban: {domain}')
return
click.echo(f'Updated domain ban: {domain}')
if row.reason:
click.echo(f'- {row.domain} ({row.reason})')
else:
click.echo(f'- {row.domain}')
@cli.group('software')
def cli_software() -> None:
'Manage banned software'
@cli_software.command('list')
@click.pass_context
def cli_software_list(ctx: click.Context) -> None:
'List all banned software'
click.echo('Banned software:')
with ctx.obj.database.session() as conn:
for row in conn.get_software_bans():
if row.reason:
click.echo(f'- {row.name} ({row.reason})')
else:
click.echo(f'- {row.name}')
@cli_software.command('ban')
@click.argument('name')
@click.option('--reason', '-r')
@click.option('--note', '-n')
@click.option(
'--fetch-nodeinfo', '-f',
is_flag = True,
help = 'Treat NAME like a domain and try to fetch the software name from nodeinfo'
)
@click.pass_context
def cli_software_ban(ctx: click.Context,
name: str,
reason: str,
note: str,
fetch_nodeinfo: bool) -> None:
'Ban software. Use RELAYS for NAME to ban relays'
with ctx.obj.database.session() as conn:
if name == 'RELAYS':
for item in RELAY_SOFTWARE:
if conn.get_software_ban(item):
click.echo(f'Relay already banned: {item}')
continue
conn.put_software_ban(item, reason or 'relay', note)
click.echo('Banned all relay software')
return
if fetch_nodeinfo:
if not (nodeinfo := asyncio.run(http.fetch_nodeinfo(name))):
click.echo(f'Failed to fetch software name from domain: {name}')
return
name = nodeinfo.sw_name
if conn.get_software_ban(name):
click.echo(f'Software already banned: {name}')
return
if not conn.put_software_ban(name, reason, note):
click.echo(f'Failed to ban software: {name}')
return
click.echo(f'Banned software: {name}')
@cli_software.command('unban')
@click.argument('name')
@click.option('--reason', '-r')
@click.option('--note', '-n')
@click.option(
'--fetch-nodeinfo', '-f',
is_flag = True,
help = 'Treat NAME like a domain and try to fetch the software name from nodeinfo'
)
@click.pass_context
def cli_software_unban(ctx: click.Context, name: str, fetch_nodeinfo: bool) -> None:
'Ban software. Use RELAYS for NAME to unban relays'
with ctx.obj.database.session() as conn:
if name == 'RELAYS':
for software in RELAY_SOFTWARE:
if not conn.del_software_ban(software):
click.echo(f'Relay was not banned: {software}')
click.echo('Unbanned all relay software')
return
if fetch_nodeinfo:
if not (nodeinfo := asyncio.run(http.fetch_nodeinfo(name))):
click.echo(f'Failed to fetch software name from domain: {name}')
return
name = nodeinfo.sw_name
if not conn.del_software_ban(name):
click.echo(f'Software was not banned: {name}')
return
click.echo(f'Unbanned software: {name}')
@cli_software.command('update')
@click.argument('name')
@click.option('--reason', '-r')
@click.option('--note', '-n')
@click.pass_context
def cli_software_update(ctx: click.Context, name: str, reason: str, note: str) -> None:
'Update the public reason or internal note for a software ban'
if not (reason or note):
ctx.fail('Must pass --reason or --note')
with ctx.obj.database.session() as conn:
if not (row := conn.update_software_ban(name, reason, note)):
click.echo(f'Failed to update software ban: {name}')
return
click.echo(f'Updated software ban: {name}')
if row.reason:
click.echo(f'- {row.name} ({row.reason})')
else:
click.echo(f'- {row.name}')
@cli.group('whitelist')
def cli_whitelist() -> None:
'Manage the instance whitelist'
@cli_whitelist.command('list')
@click.pass_context
def cli_whitelist_list(ctx: click.Context) -> None:
'List all the instances in the whitelist'
click.echo('Current whitelisted domains:')
with ctx.obj.database.session() as conn:
for row in conn.get_domain_whitelist():
click.echo(f'- {row.domain}')
@cli_whitelist.command('add')
@click.argument('domain')
@click.pass_context
def cli_whitelist_add(ctx: click.Context, domain: str) -> None:
'Add a domain to the whitelist'
with ctx.obj.database.session() as conn:
if conn.get_domain_whitelist(domain):
click.echo(f'Instance already in the whitelist: {domain}')
return
conn.put_domain_whitelist(domain)
click.echo(f'Instance added to the whitelist: {domain}')
@cli_whitelist.command('remove')
@click.argument('domain')
@click.pass_context
def cli_whitelist_remove(ctx: click.Context, domain: str) -> None:
'Remove an instance from the whitelist'
with ctx.obj.database.session() as conn:
if not conn.del_domain_whitelist(domain):
click.echo(f'Domain not in the whitelist: {domain}')
return
if conn.get_config('whitelist-enabled'):
if conn.del_inbox(domain):
click.echo(f'Removed inbox for domain: {domain}')
click.echo(f'Removed domain from the whitelist: {domain}')
@cli_whitelist.command('import')
@click.pass_context
def cli_whitelist_import(ctx: click.Context) -> None:
'Add all current instances to the whitelist'
with ctx.obj.database.session() as conn:
for row in conn.get_inboxes():
if conn.get_domain_whitelist(row.domain) is not None:
click.echo(f'Domain already in whitelist: {row.domain}')
continue
conn.put_domain_whitelist(row.domain)
click.echo('Imported whitelist from inboxes')
def main() -> None:
cli(prog_name='activityrelay')