mirror of
https://git.pleroma.social/pleroma/relay.git
synced 2025-04-20 17:46:43 +00:00
Compare commits
4 commits
2e373eddc9
...
dec378fbfc
Author | SHA1 | Date | |
---|---|---|---|
|
dec378fbfc | ||
|
ac0cddd65a | ||
|
87e45553ca | ||
|
e85215d986 |
21 changed files with 1143 additions and 1071 deletions
|
@ -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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from relay.manage import main
|
||||
from relay.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
@ -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
65
relay/cli/__init__.py
Normal 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
347
relay/cli/base.py
Normal 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
51
relay/cli/config.py
Normal 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
169
relay/cli/inbox.py
Normal 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
89
relay/cli/instance_ban.py
Normal 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
82
relay/cli/request.py
Normal 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
143
relay/cli/software_ban.py
Normal 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
66
relay/cli/user.py
Normal 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
73
relay/cli/whitelist.py
Normal 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")
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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__}"
|
||||
}
|
||||
|
||||
|
|
|
@ -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: ...
|
||||
|
|
1044
relay/manage.py
1044
relay/manage.py
File diff suppressed because it is too large
Load diff
|
@ -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],
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue