Compare commits

..

4 commits

Author SHA1 Message Date
Izalia Mae
dec378fbfc allow cache cleanup thread to be stopped on shutdown 2025-02-12 15:19:55 -05:00
Izalia Mae
ac0cddd65a actually fix python 3.10 compat 2025-02-12 15:14:46 -05:00
Izalia Mae
87e45553ca only require typing-extensions on python < 3.11 2025-02-12 14:52:43 -05:00
Izalia Mae
e85215d986 split manage.py and multiple small changes
* add `typing-extensions` dev dependency
* make sure `Self` import falls back to `typing-extensions`
* let setuptools find sub-packages
* pass global `Application` instance to cli commands
* move `RELAY_SOFTWARE` to relay/misc.py
* allow `str` for `follow` in `Message.new_unfollow`
2025-02-12 14:48:02 -05:00
21 changed files with 1143 additions and 1071 deletions

View file

@ -27,7 +27,7 @@ dependencies = [
"aiohttp >= 3.9.5",
"argon2-cffi == 23.1.0",
"barkshark-lib >= 0.2.3, < 0.3.0",
"barkshark-sql >= 0.2.0, < 0.3.0",
"barkshark-sql >= 0.2.5, < 0.3.0",
"click == 8.1.2",
"docstring-parser == 0.16",
"hamlish == 0.4.0",
@ -63,6 +63,7 @@ dev = [
"flake8 == 7.1.1",
"mypy == 1.13.0",
"pyinstaller == 6.10.0",
"typing-extensions == 4.12.2; python_version < '3.11'",
]
docs = [
"furo == 2024.1.29",
@ -72,11 +73,6 @@ docs = [
[tool.setuptools]
zip-safe = false
packages = [
"relay",
"relay.database",
"relay.views",
]
include-package-data = true
license-files = [
"LICENSE",
@ -94,6 +90,9 @@ relay = [
[tool.setuptools.dynamic.version]
attr = "relay.__version__"
[tool.setuptools.packages.find]
include = ["relay*"]
[tool.mypy]
show_traceback = true
install_types = true

View file

@ -1,4 +1,4 @@
from relay.manage import main
from relay.cli import main
if __name__ == "__main__":

View file

@ -18,7 +18,7 @@ from datetime import datetime, timedelta
from mimetypes import guess_type
from pathlib import Path
from threading import Event, Thread
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from . import logger as logging
from .cache import Cache, get_cache
@ -30,6 +30,13 @@ from .misc import JSON_PATHS, TOKEN_PATHS, Message, Response
from .template import Template
from .workers import PushWorkers
if TYPE_CHECKING:
try:
from typing import Self
except ImportError:
from typing_extensions import Self
def get_csp(request: web.Request) -> str:
data = [
@ -41,7 +48,7 @@ def get_csp(request: web.Request) -> str:
"img-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
f"manifest-src 'self' https://{request.app["config"].domain}"
f"manifest-src 'self' https://{request.app['config'].domain}"
]
return "; ".join(data) + ";"
@ -51,6 +58,14 @@ class Application(web.Application):
DEFAULT: Application | None = None
@classmethod
def default(cls: type[Self]) -> Application:
if cls.DEFAULT is None:
raise ValueError("Default application not set")
return cls.DEFAULT
def __init__(self, cfgpath: Path | None, dev: bool = False):
web.Application.__init__(self,
middlewares = [
@ -269,7 +284,7 @@ class CacheCleanupThread(Thread):
def run(self) -> None:
while self.running.is_set():
time.sleep(3600)
self.running.wait(3600)
logging.verbose("Removing old cache items")
self.app.cache.delete_old(14)

65
relay/cli/__init__.py Normal file
View file

@ -0,0 +1,65 @@
from __future__ import annotations
import click
import json
import multiprocessing
from collections.abc import Callable
from functools import update_wrapper
from pathlib import Path
from typing import Concatenate, ParamSpec, TypeVar
from .. import __version__
from ..application import Application
from ..misc import IS_DOCKER
P = ParamSpec("P")
R = TypeVar("R")
@click.group("cli", context_settings = {"show_default": True})
@click.option("--config", "-c", type = Path, help = "path to the relay 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)
def pass_app(func: Callable[Concatenate[Application, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(Application.default(), *args, **kwargs)
return update_wrapper(wrapper, func)
def main() -> None:
multiprocessing.freeze_support()
cli(prog_name="activityrelay")
from . import ( # noqa: E402
base,
config as config_cli,
inbox,
instance_ban,
request,
software_ban,
user,
whitelist
)

347
relay/cli/base.py Normal file
View file

@ -0,0 +1,347 @@
import aputils
import click
from pathlib import Path
from shutil import copyfile
from . import cli, pass_app
from .. import logger as logging
from ..application import Application
from ..compat import RelayConfig, RelayDatabase
from ..config import Config
from ..database import TABLES, get_database
from ..misc import IS_DOCKER, RELAY_SOFTWARE
from ..views import ROUTES
def check_alphanumeric(text: str) -> str:
if not text.isalnum():
raise click.BadParameter("String not alphanumeric")
return text
@cli.command("convert")
@click.option("--old-config", "-o", help = "Path to the config file to convert from")
@pass_app
def cli_convert(app: Application, old_config: str) -> None:
"Convert an old config and jsonld database to the new format."
old_config = str(Path(old_config).expanduser().resolve()) if old_config else str(app.config.path)
backup = app.config.path.parent.joinpath(f"{app.config.path.stem}.backup.yaml")
if str(old_config) == str(app.config.path) and not backup.exists():
logging.info("Created backup config @ %s", backup)
copyfile(app.config.path, backup)
config = RelayConfig(old_config)
config.load()
database = RelayDatabase(config)
database.load()
app.config.set("listen", config["listen"])
app.config.set("port", config["port"])
app.config.set("workers", config["workers"])
app.config.set("sq_path", config["db"].replace("jsonld", "sqlite3"))
app.config.set("domain", config["host"])
app.config.save()
with get_database(app.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.get("whitelist-enabled", False))
with click.progressbar(
database["relay-list"].values(),
label = "Inboxes".ljust(15),
width = 0
) as inboxes:
for inbox in inboxes:
match inbox["software"]:
case "akkoma" | "pleroma":
inbox["actor"] = f"https://{inbox['domain']}/relay"
case "mastodon":
inbox["actor"] = f"https://{inbox['domain']}/actor"
case _:
inbox["actor"] = None
conn.put_inbox(
inbox["domain"],
inbox["inbox"],
actor = inbox["actor"],
followid = inbox["followid"],
software = inbox["software"]
)
with click.progressbar(
config.get("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.get("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.get("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("db-maintenance")
@pass_app
def cli_db_maintenance(app: Application) -> None:
"Perform maintenance tasks on the database"
if app.config.db_type == "postgres":
return
with app.database.session(False) as s:
with s.transaction():
s.fix_timestamps()
with s.execute("VACUUM"):
pass
@cli.command("edit-config")
@click.option("--editor", "-e", help = "Text editor to use")
@pass_app
def cli_editconfig(app: Application, editor: str) -> None:
"Edit the config file"
click.edit(
editor = editor,
filename = str(app.config.path)
)
@cli.command("run")
@click.option("--dev", "-d", is_flag=True, help="Enable developer mode")
@pass_app
def cli_run(app: Application, dev: bool = False) -> None:
"Run the relay"
if app.config.domain.endswith("example.com") or app.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
for method, path, handler in ROUTES:
app.router.add_route(method, path, handler)
app["dev"] = dev
app.run()
@cli.command("setup")
@click.option("--skip-questions", "-s", is_flag = True,
help = "Assume the config file is correct and just setup the database")
@pass_app
def cli_setup(app: Application, skip_questions: bool) -> None:
"Generate a new config and create the database"
if app.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 app.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:
app.config.domain = click.prompt(
"What domain will the relay be hosted on?",
default = app.config.domain
)
if not app.config.domain.endswith("example.com"):
break
click.echo("The domain must not end with \"example.com\"")
if not IS_DOCKER:
app.config.listen = click.prompt(
"Which address should the relay listen on?",
default = app.config.listen
)
app.config.port = click.prompt(
"What TCP port should the relay listen on?",
default = app.config.port,
type = int
)
app.config.db_type = click.prompt(
"Which database backend will be used?",
default = app.config.db_type,
type = click.Choice(["postgres", "sqlite"], case_sensitive = False)
)
if app.config.db_type == "sqlite" and not IS_DOCKER:
app.config.sq_path = click.prompt(
"Where should the database be stored?",
default = app.config.sq_path
)
elif app.config.db_type == "postgres":
config_postgresql(app.config)
app.config.ca_type = click.prompt(
"Which caching backend?",
default = app.config.ca_type,
type = click.Choice(["database", "redis"], case_sensitive = False)
)
if app.config.ca_type == "redis":
app.config.rd_host = click.prompt(
"What IP address, hostname, or unix socket does the server listen on?",
default = app.config.rd_host
)
app.config.rd_port = click.prompt(
"What port does the server listen on?",
default = app.config.rd_port,
type = int
)
app.config.rd_user = click.prompt(
"Which user will authenticate with the server",
default = app.config.rd_user
)
app.config.rd_pass = click.prompt(
"User password",
hide_input = True,
show_default = False,
default = app.config.rd_pass or ""
) or None
app.config.rd_database = click.prompt(
"Which database number to use?",
default = app.config.rd_database,
type = int
)
app.config.rd_prefix = click.prompt(
"What text should each cache key be prefixed with?",
default = app.config.rd_database,
type = check_alphanumeric
)
app.config.save()
config = {
"private-key": aputils.Signer.new("n/a").export()
}
with app.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("switch-backend")
@pass_app
def cli_switchbackend(app: Application) -> None:
"""
Copy the database from one backend to the other
Be sure to set the database type to the backend you want to convert from. For instance, set
the database type to `sqlite`, fill out the connection details for postgresql, and the
data from the sqlite database will be copied to the postgresql database. This only works if
the database in postgresql already exists.
"""
config = Config(app.config.path, load = True)
config.db_type = "sqlite" if config.db_type == "postgres" else "postgres"
if config.db_type == "postgres":
if click.confirm("Setup PostgreSQL configuration?"):
config_postgresql(config)
order = ("SQLite", "PostgreSQL")
click.pause("Make sure the database and user already exist before continuing")
else:
order = ("PostgreSQL", "SQLite")
click.echo(f"About to convert from {order[0]} to {order[1]}...")
database = get_database(config, migrate = False)
with database.session(True) as new, app.database.session(False) as old:
if click.confirm("All tables in the destination database will be dropped. Continue?"):
new.drop_tables()
new.create_tables()
for table in TABLES.keys():
for row in old.execute(f"SELECT * FROM {table}"):
new.insert(table, row).close()
config.save()
click.echo("Done!")
def config_postgresql(config: Config) -> None:
config.pg_name = click.prompt(
"What is the name of the database?",
default = config.pg_name
)
config.pg_host = click.prompt(
"What IP address, hostname, or unix socket does the server listen on?",
default = config.pg_host,
)
config.pg_port = click.prompt(
"What port does the server listen on?",
default = config.pg_port,
type = int
)
config.pg_user = click.prompt(
"Which user will authenticate with the server?",
default = config.pg_user
)
config.pg_pass = click.prompt(
"User password",
hide_input = True,
show_default = False,
default = config.pg_pass or ""
) or None

51
relay/cli/config.py Normal file
View file

@ -0,0 +1,51 @@
import click
from typing import Any
from . import cli, pass_app
from ..application import Application
@cli.group("config")
def cli_config() -> None:
"Manage the relay settings stored in the database"
@cli_config.command("list")
@pass_app
def cli_config_list(app: Application) -> None:
"List the current relay config"
click.echo("Relay Config:")
with app.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")
@pass_app
def cli_config_set(app: Application, key: str, value: Any) -> None:
"Set a config value"
try:
with app.database.session() as conn:
new_value = conn.put_config(key, value)
except Exception:
click.echo(f"Invalid config name: {key}")
return
click.echo(f"{key}: {repr(new_value)}")

169
relay/cli/inbox.py Normal file
View file

@ -0,0 +1,169 @@
import asyncio
import click
from urllib.parse import urlparse
from . import cli, pass_app
from .. import http_client as http
from ..application import Application
from ..database.schema import Instance
from ..misc import ACTOR_FORMATS, Message
@cli.group("inbox")
def cli_inbox() -> None:
"Manage the inboxes in the database"
@cli_inbox.command("list")
@pass_app
def cli_inbox_list(app: Application) -> None:
"List the connected instances or relays"
click.echo("Connected to the following instances or relays:")
with app.database.session() as conn:
for row in conn.get_inboxes():
click.echo(f"- {row.inbox}")
@cli_inbox.command("follow")
@click.argument("actor")
@pass_app
def cli_inbox_follow(app: Application, actor: str) -> None:
"Follow an actor (Relay must be running)"
instance: Instance | None = None
with app.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 = app.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")
@pass_app
def cli_inbox_unfollow(app: Application, actor: str) -> None:
"Unfollow an actor (Relay must be running)"
instance: Instance | None = None
with app.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 = app.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 = app.config.domain,
actor = actor,
follow = {
"type": "Follow",
"object": actor,
"actor": f"https://{app.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", help = "Nodeinfo software name of the instance")
@pass_app
def cli_inbox_add(
app: Application,
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 app.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")
@pass_app
def cli_inbox_remove(app: Application, inbox: str) -> None:
"Remove an inbox from the database"
with app.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}")

89
relay/cli/instance_ban.py Normal file
View file

@ -0,0 +1,89 @@
import click
from . import cli, pass_app
from ..application import Application
@cli.group("instance")
def cli_instance() -> None:
"Manage instance bans"
@cli_instance.command("list")
@pass_app
def cli_instance_list(app: Application) -> None:
"List all banned instances"
click.echo("Banned domains:")
with app.database.session() as conn:
for row in conn.get_domain_bans():
if row.reason:
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")
@pass_app
def cli_instance_ban(app: Application, domain: str, reason: str, note: str) -> None:
"Ban an instance and remove the associated inbox if it exists"
with app.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")
@pass_app
def cli_instance_unban(app: Application, domain: str) -> None:
"Unban an instance"
with app.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
@pass_app
def cli_instance_update(
app: Application,
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 app.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}")

82
relay/cli/request.py Normal file
View file

@ -0,0 +1,82 @@
import asyncio
import click
from . import cli, pass_app
from .. import http_client as http
from ..application import Application
from ..misc import Message
@cli.group("request")
def cli_request() -> None:
"Manage follow requests"
@cli_request.command("list")
@pass_app
def cli_request_list(app: Application) -> None:
"List all current follow requests"
click.echo("Follow requests:")
with app.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")
@pass_app
def cli_request_accept(app: Application, domain: str) -> None:
"Accept a follow request"
try:
with app.database.session() as conn:
instance = conn.put_request_response(domain, True)
except KeyError:
click.echo("Request not found")
return
message = Message.new_response(
host = app.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 = app.config.domain,
actor = instance.actor
)
asyncio.run(http.post(instance.inbox, message, instance))
@cli_request.command("deny")
@click.argument("domain")
@pass_app
def cli_request_deny(app: Application, domain: str) -> None:
"Accept a follow request"
try:
with app.database.session() as conn:
instance = conn.put_request_response(domain, False)
except KeyError:
click.echo("Request not found")
return
response = Message.new_response(
host = app.config.domain,
actor = instance.actor,
followid = instance.followid,
accept = False
)
asyncio.run(http.post(instance.inbox, response, instance))

143
relay/cli/software_ban.py Normal file
View file

@ -0,0 +1,143 @@
import asyncio
import click
from . import cli, pass_app
from .. import http_client as http
from ..application import Application
from ..misc import RELAY_SOFTWARE
@cli.group("software")
def cli_software() -> None:
"Manage banned software"
@cli_software.command("list")
@pass_app
def cli_software_list(app: Application) -> None:
"List all banned software"
click.echo("Banned software:")
with app.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"
)
@pass_app
def cli_software_ban(app: Application,
name: str,
reason: str,
note: str,
fetch_nodeinfo: bool) -> None:
"Ban software. Use RELAYS for NAME to ban relays"
with app.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"
)
@pass_app
def cli_software_unban(app: Application, name: str, fetch_nodeinfo: bool) -> None:
"Ban software. Use RELAYS for NAME to unban relays"
with app.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
@pass_app
def cli_software_update(
app: Application,
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 app.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}")

66
relay/cli/user.py Normal file
View file

@ -0,0 +1,66 @@
import click
from . import cli, pass_app
from ..application import Application
@cli.group("user")
def cli_user() -> None:
"Manage local users"
@cli_user.command("list")
@pass_app
def cli_user_list(app: Application) -> None:
"List all local users"
click.echo("Users:")
with app.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)
@pass_app
def cli_user_create(app: Application, username: str, handle: str) -> None:
"Create a new local user"
with app.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")
@pass_app
def cli_user_delete(app: Application, username: str) -> None:
"Delete a local user"
with app.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}")

73
relay/cli/whitelist.py Normal file
View file

@ -0,0 +1,73 @@
import click
from . import cli, pass_app
from ..application import Application
from ..database.schema import Whitelist
@cli.group("whitelist")
def cli_whitelist() -> None:
"Manage the instance whitelist"
@cli_whitelist.command("list")
@click.pass_context
@pass_app
def cli_whitelist_list(app: Application, ctx: click.Context) -> None:
"List all the instances in the whitelist"
click.echo("Current whitelisted domains:")
with app.database.session() as conn:
for row in conn.execute("SELECT * FROM whitelist").all(Whitelist):
click.echo(f"- {row.domain}")
@cli_whitelist.command("add")
@click.argument("domain")
@pass_app
def cli_whitelist_add(app: Application, domain: str) -> None:
"Add a domain to the whitelist"
with app.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")
@pass_app
def cli_whitelist_remove(app: Application, domain: str) -> None:
"Remove an instance from the whitelist"
with app.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")
@pass_app
def cli_whitelist_import(app: Application) -> None:
"Add all current instances to the whitelist"
with app.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")

View file

@ -13,8 +13,12 @@ from typing import TYPE_CHECKING, Any
from .misc import IS_DOCKER
if TYPE_CHECKING:
try:
from typing import Self
except ImportError:
from typing_extensions import Self
if platform.system() == "Windows":
import multiprocessing

View file

@ -3,8 +3,8 @@ import sqlite3
from blib import Date, File
from bsql import Database
from .config import THEMES, ConfigData
from .connection import RELAY_SOFTWARE, Connection
from .config import ConfigData
from .connection import Connection
from .schema import TABLES, VERSIONS, migrate_0
from .. import logger as logging

View file

@ -11,8 +11,12 @@ from typing import TYPE_CHECKING, Any
from .. import logger as logging
if TYPE_CHECKING:
try:
from typing import Self
except ImportError:
from typing_extensions import Self
THEMES = {
"default": {

View file

@ -22,14 +22,6 @@ from ..misc import Message, get_app
if TYPE_CHECKING:
from ..application import Application
RELAY_SOFTWARE = [
"activityrelay", # https://git.pleroma.social/pleroma/relay
"activity-relay", # https://github.com/yukimochi/Activity-Relay
"aoderelay", # https://git.asonix.dog/asonix/relay
"feditools-relay", # https://git.ptzo.gdn/feditools/relay
"buzzrelay" # https://github.com/astro/buzzrelay
]
class Connection(SqlConnection):
hasher = PasswordHasher(

View file

@ -20,7 +20,7 @@ if TYPE_CHECKING:
T = TypeVar("T", bound = JsonBase[Any])
HEADERS = {
"Accept": f"{MIMETYPES["activity"]}, {MIMETYPES["json"]};q=0.9",
"Accept": f"{MIMETYPES['activity']}, {MIMETYPES['json']};q=0.9",
"User-Agent": f"ActivityRelay/{__version__}"
}

View file

@ -8,8 +8,12 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
try:
from typing import Self
except ImportError:
from typing_extensions import Self
class LoggingMethod(Protocol):
def __call__(self, msg: Any, *args: Any, **kwargs: Any) -> None: ...

File diff suppressed because it is too large Load diff

View file

@ -12,9 +12,14 @@ from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, overload
from uuid import uuid4
if TYPE_CHECKING:
from typing import Self
from .application import Application
try:
from typing import Self
except ImportError:
from typing_extensions import Self
T = TypeVar("T")
@ -57,6 +62,14 @@ JSON_PATHS: tuple[str, ...] = (
"/oauth/revoke"
)
RELAY_SOFTWARE = [
"activityrelay", # https://git.pleroma.social/pleroma/relay
"activity-relay", # https://github.com/yukimochi/Activity-Relay
"aoderelay", # https://git.asonix.dog/asonix/relay
"feditools-relay", # https://git.ptzo.gdn/feditools/relay
"buzzrelay" # https://github.com/astro/buzzrelay
]
TOKEN_PATHS: tuple[str, ...] = (
"/logout",
"/admin",
@ -168,7 +181,7 @@ class Message(aputils.Message):
@classmethod
def new_unfollow(cls: type[Self], host: str, actor: str, follow: dict[str, str]) -> Self:
def new_unfollow(cls: type[Self], host: str, actor: str, follow: dict[str, str] | str) -> Self:
return cls.new(aputils.ObjectType.UNDO, {
"id": f"https://{host}/activities/{uuid4()}",
"to": [actor],

View file

@ -7,7 +7,7 @@ from urllib.parse import unquote
from .base import METHODS, register_route
from ..database import THEMES
from ..database.config import THEMES
from ..logger import LogLevel
from ..misc import Response