diff --git a/firm_server/cli/__init__.py b/firm_server/cli/__init__.py new file mode 100644 index 0000000..7a9f6df --- /dev/null +++ b/firm_server/cli/__init__.py @@ -0,0 +1,50 @@ +import logging +from dataclasses import dataclass + +import click +import coloredlogs +import dotenv +from firm.interfaces import ResourceStore + +from firm_server.config import ServerConfig, load_config +from firm_server.store import initialize_store + +dotenv.load_dotenv() + +log = logging.root + + +@dataclass +class Context: + store: ResourceStore + config: ServerConfig + + +@click.group(context_settings=dict(auto_envvar_prefix="FIRM")) +@click.option("--config", type=click.File("r"), envvar="FIRM_CONFIG") +@click.pass_context +def cli(ctx: click.Context, config: click.File): + """FIRM - Federated Information Resource Manager + + To get subcommand help, use ' --help' + """ + config_data = load_config(config) + store = initialize_store(config_data) + ctx.obj = Context(store, config_data) + coloredlogs.install() + + +class LiteralChoice(click.ParamType): + name = "literal" + + def __init__(self, literal): + self.values = literal.__args__ + + def convert(self, value, param, ctx): + if value in self.values: + return value + self.fail( + f'{value} is not a valid choice. Choose from {", ".join(self.values)}.', + param, + ctx, + ) diff --git a/firm_server/cli/actor.py b/firm_server/cli/actor.py new file mode 100644 index 0000000..76ab232 --- /dev/null +++ b/firm_server/cli/actor.py @@ -0,0 +1,282 @@ +import json +import uuid +from typing import Any +from urllib.parse import urlparse + +import click +from firm.auth.keys import create_key_pair +from firm.interfaces import FIRM_NS, get_url_prefix + +from firm_server.utils import async_command + +from . import Context, cli + + +@cli.group +def actor(): + """Actor management""" + + +def _property(p): + if "=" in p: + name, value = p.split("=") + else: + name = p + value = p + if value.startswith("http"): + url = urlparse(value) + value = ( + f'' + f'' + f'{url.netloc}{url.path}' + ) + return { + "type": "PropertyValue", + "name": name, + "value": value, + } + + +@actor.command("create") +@click.argument("uri") +@click.argument("name") +@click.argument("handle") +@click.option("--role", "roles", multiple=True, default=[]) +@click.option("--description") +@click.option("--header-image") +@click.option("--avatar") +@click.option("--hashtag", "hashtags", multiple=True) +@click.option("--property", "properties", multiple=True) +@click.pass_obj +@async_command +async def actor_create( + ctx: Context, + uri: str, + name: str, + handle: str, + roles: list[str], + description: str | None, + header_image: str | None, + avatar: str | None, + hashtags: list[str], + properties: list[str], +) -> None: + """Create a new actor""" + store = ctx.store + key_pair = create_key_pair() + url = urlparse(uri) + tenant_prefix = get_url_prefix(uri) + actor_resource: dict[str, Any] = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": uri, + "preferredUsername": handle, + "type": "Person", + "url": uri, + "name": name, + "publicKey": { + "id": f"{uri}#main-key", + "owner": f"{uri}", + "publicKeyPem": key_pair.public, + }, + # "subtitle": "Europe's news in English", + "inbox": f"{uri}/inbox", + "outbox": f"{uri}/outbox", + "followers": f"{uri}/followers", + "alsoKnownAs": f"acct:{handle}@{url.hostname}", + } + if description: + actor_resource["summary"] = description + if header_image: + actor_resource["image"] = header_image + if avatar: + actor_resource["icon"] = avatar + if hashtags: + actor_resource["tag"] = [ + { + "type": "Hashtag", + "href": f"{tenant_prefix}/tag/{h}", + "name": f"#{h}", + } + for h in hashtags + ] + if properties: + actor_resource["attachment"] = [_property(p) for p in properties] + resources: list[dict] = [ + actor_resource, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": f"{uri}/inbox", + "attributedTo": uri, + "type": "OrderedCollection", + "totalItems": 0, + }, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": f"{uri}/outbox", + "attributedTo": uri, + "type": "OrderedCollection", + "totalItems": 0, + }, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": f"{uri}/following", + "attributedTo": uri, + "type": "Collection", + "totalItems": 0, + }, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": f"{uri}/followers", + "attributedTo": uri, + "type": "Collection", + "totalItems": 0, + }, + { + "id": f"urn:uuid:{uuid.uuid4()}", + "attributedTo": uri, + "type": [FIRM_NS.Credentials.value], + FIRM_NS.privateKey.value: key_pair.private, + FIRM_NS.role.value: roles, + }, + ] + for r in resources: + if await store.is_stored(r["id"]): + await store.remove(r["id"]) + await store.put(r) + print(f"Wrote {r['id']}") + + +@actor.command("update") +@click.argument("uri") +@click.option("--name") +@click.option("--handle") +@click.option("--role", "roles", multiple=True, default=[]) +@click.option("--description") +@click.option("--header-image") +@click.option("--avatar") +@click.option("--hashtag", "hashtags", multiple=True) +@click.option("--property", "properties", multiple=True) +@click.option("--add-property", "added_properties", multiple=True) +@click.option("--remove-property", "removed_properties", multiple=True) +@click.option("--verbose", "-v", is_flag=True) +@click.pass_obj +@async_command +async def actor_update( + ctx: Context, + uri: str, + name: str | None, + handle: str | None, + roles: list[str], + description: str | None, + header_image: str | None, + avatar: str | None, + hashtags: list[str], + properties: list[str], + added_properties: list[str], + removed_properties: list[str], + verbose: bool, +) -> None: + """Update actor properties""" + store = ctx.store + actor_resource = await store.get(uri) + credentials = None + if not actor_resource: + raise click.ClickException(f"Actor not found: {uri}") + if name: + actor_resource["name"] = name + if handle: + url = urlparse(uri) + actor_resource["preferredUsername"] = handle + actor_resource["alsoKnownAs"] = f"acct:{handle}@{url.hostname}" + if roles: + credentials = await store.query_one( + { + "@prefix": "urn:", # private + "type": FIRM_NS.Credentials.value, + "attributedTo": uri, + } + ) + credentials[FIRM_NS.role.value] = roles + if description: + actor_resource["summary"] = description + if header_image: + actor_resource["image"] = header_image + if avatar: + actor_resource["icon"] = avatar + if hashtags: + tenant_prefix = get_url_prefix(uri) + actor_resource["tag"] = [ + { + "type": "Hashtag", + "href": f"{tenant_prefix}/tag/{h}", + "name": f"#{h}", + } + for h in hashtags + ] + if properties: + actor_resource["attachment"] = [_property(p) for p in list(properties)] + if added_properties: + actor_resource["attachment"] = actor_resource.get("attachment", []) + [ + _property(p) for p in added_properties + ] + if removed_properties: + actor_resource["attachment"] = [ + p + for p in actor_resource.get("attachment", []) + if p["name"] not in removed_properties + ] + if credentials: + if verbose: + print(json.dumps(credentials, indent=2)) + await store.put(credentials) + if verbose: + print(json.dumps(actor_resource, indent=2)) + await store.put(actor_resource) + + +@actor.group +def outbox(): + """Outbox management""" + + +@outbox.command("clean") +@click.argument("uri") +@click.pass_obj +@async_command +async def actor_outbox_clean(ctx: Context, uri: str): + store = ctx.store + actor = await store.get(uri) + box = await store.get(actor["outbox"]) + if isinstance(box, str): + box = await store.get(box) + if activity_uris := box.get("orderedItems", []): + for activity_uri in activity_uris: + if activity := await store.get(activity_uri): + obj = activity.get("object") + if isinstance(obj, str): + obj = await store.get(obj) + if obj.get("attributedTo") == actor["id"]: + await store.remove(obj["id"]) + print(f"removed object {obj}") + await store.remove(activity_uri) + print(f"removed activity {activity_uri}") + box.pop("orderedItems") + await store.put(box) + + +@actor.group +def inbox(): + """Inbox management""" + + +@inbox.command("clean") +@click.argument("uri") +@click.pass_obj +@async_command +async def actor_inbox_clean(ctx: Context, uri: str): + store = ctx.store + actor = await store.get(uri) + box = await store.get(actor["inbox"]) + box.pop("orderedItems") + await store.put(box) diff --git a/firm_server/cli/main.py b/firm_server/cli/main.py new file mode 100644 index 0000000..88a513b --- /dev/null +++ b/firm_server/cli/main.py @@ -0,0 +1,10 @@ +from importlib import import_module + +from . import cli + +import_module("firm_server.cli.serve") +import_module("firm_server.cli.actor") +import_module("firm_server.cli.resource") + +if __name__ == "__main__": + cli() diff --git a/firm_server/cli/resource.py b/firm_server/cli/resource.py new file mode 100644 index 0000000..86653a5 --- /dev/null +++ b/firm_server/cli/resource.py @@ -0,0 +1,70 @@ +import json +from typing import IO + +import click + +from firm_server.utils import async_command + +from . import Context, cli + + +@cli.group +def resource(): + """Resource management""" + + +@resource.command("add") +@click.argument("file", type=click.File("r")) +@click.pass_obj +@async_command +async def add_resource(ctx: Context, file: IO) -> None: + """Add a resource from a file.""" + store = ctx.store + resource = json.loads(file.read()) + if "id" not in resource: + raise click.ClickException("Resource missing 'id' field") + await store.put(resource) + + +@resource.command("remove") +@click.argument("uri") +@click.pass_obj +@async_command +async def remove_resource(ctx: Context, uri: str) -> None: + """Remove a resource""" + store = ctx.store + await store.remove(uri) + + +@resource.command("get") +@click.argument("uri") +@click.pass_obj +@async_command +async def get_resource(ctx: Context, uri: str) -> None: + """Get a resource""" + store = ctx.store + resource = await store.get(uri) + print(json.dumps(resource, indent=2)) + + +@resource.command("query") +@click.pass_obj +@click.option("--prefix", "prefix") +@click.option("--type", "resource_type") +@async_command +async def resource_query( + ctx: Context, resource_type: str | None, prefix: str | None +) -> None: + """Query resources""" + store = ctx.store + query = {} + if prefix: + query["@prefix"] = prefix + if resource_type: + query["type"] = resource_type + resources = await store.query(query) + print(json.dumps(resources, indent=2)) + + +if __name__ == "__main__": + resource() diff --git a/firm_server/cli.py b/firm_server/cli/serve.py similarity index 78% rename from firm_server/cli.py rename to firm_server/cli/serve.py index a8b42ad..f70b453 100644 --- a/firm_server/cli.py +++ b/firm_server/cli/serve.py @@ -1,27 +1,15 @@ import asyncio import logging import ssl -from typing import IO import click -import dotenv import uvicorn from colorama import Fore, Style from firm_server.exceptions import ServerException from firm_server.server import run -dotenv.load_dotenv() - -log = logging.root - - -@click.group(context_settings=dict(auto_envvar_prefix="FIRM")) -def cli(): - """FIRM - Federated Information Resource Manager - - To get subcommand help, use ' --help' - """ +from . import Context, LiteralChoice, cli def print_banner() -> None: @@ -37,32 +25,9 @@ def print_banner() -> None: # log.info(line) -class LiteralChoice(click.ParamType): - name = "literal" - - def __init__(self, literal): - self.values = literal.__args__ - - def convert(self, value, param, ctx): - if value in self.values: - return value - self.fail( - f'{value} is not a valid choice. Choose from {", ".join(self.values)}.', - param, - ctx, - ) - - @cli.command @click.option("--host", metavar="HOST", default="0.0.0.0", show_default=True) @click.option("--port", metavar="PORT", type=int, default="7000", show_default=True) -@click.option( - "--config", - type=click.File(), - default="firm.json", - metavar="FILEPATH", - show_default=True, -) @click.option( "--verbose", "-v", @@ -140,18 +105,18 @@ def convert(self, value, param, ctx): show_default=True, ) @click.option("--no-banner", type=bool, is_flag=True, default=False, show_default=True) -def server(config: IO, verbose: bool, no_banner: bool, **kwargs): - """Run the FIRM server""" +@click.pass_obj +def serve(ctx: Context, verbose: bool, no_banner: bool, **kwargs): + """Run the server""" try: if not no_banner: print_banner() - run(config, verbose, kwargs) + if verbose: + logging.root.setLevel(logging.DEBUG) + logging.debug("DEBUG") + run(ctx.config, verbose, kwargs) except (asyncio.exceptions.CancelledError, KeyboardInterrupt): pass except ServerException as ex: logging.log(ex.level, ex.message) raise SystemExit(1) - - -if __name__ == "__main__": - cli() diff --git a/firm_server/html/templates/actor.jinja2 b/firm_server/html/templates/actor.jinja2 index 845aace..36656f1 100644 --- a/firm_server/html/templates/actor.jinja2 +++ b/firm_server/html/templates/actor.jinja2 @@ -1,7 +1,9 @@ {% extends "layout.jinja2" %} -{% set header_image = resource.image.url if resource.image else "https://placehold.co/1200x300?text=" + resource.preferredUsername %} -{% set avatar_image = resource.icon.url if resource.icon else "https://placehold.co/80x80?text=" + resource.preferredUsername %} +{% set header_image = resource.image.url if resource.image else "https://placehold.co/1200x300?text=" + +resource.preferredUsername %} +{% set avatar_image = resource.icon.url if resource.icon else "https://placehold.co/80x80?text=" + +resource.preferredUsername %} {% block style %} @@ -252,6 +254,7 @@

May 13, 2024 (TODO)

--> + {% if timeline %}
Posts
Posts and replies
@@ -280,5 +283,6 @@
{% endfor %} + {% endif %} {% endblock main %} diff --git a/firm_server/server.py b/firm_server/server.py index 2a9890e..37cea27 100644 --- a/firm_server/server.py +++ b/firm_server/server.py @@ -1,15 +1,13 @@ import asyncio import contextlib import logging -import typing -import coloredlogs import uvicorn from starlette.applications import Starlette -from firm_server.config import ServerConfig, load_config +from firm_server.config import ServerConfig from firm_server.routes import get_routes -from firm_server.store import get_store, initialize_store +from firm_server.store import get_store log = logging.getLogger(__name__ if __name__ != "__main__" else "firm_server.main") @@ -50,14 +48,7 @@ def handle_exit(self, sig: int, frame) -> None: return super().handle_exit(sig, frame) -async def async_run(config_stream: typing.IO, verbose: bool, kwargs) -> None: - coloredlogs.install(level=logging.DEBUG if verbose else logging.INFO) - - config = load_config(config_stream) - logging.debug("config: %s", config) - - initialize_store(config) - +async def async_run(config: ServerConfig, verbose: bool, kwargs) -> None: def app_factory_with_config() -> Starlette: return app_factory(config) @@ -91,5 +82,5 @@ def app_factory_with_config() -> Starlette: logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) -def run(config: typing.IO, verbose: bool, kwargs): +def run(config: ServerConfig, verbose: bool, kwargs): asyncio.run(async_run(config, verbose, kwargs)) diff --git a/pyproject.toml b/pyproject.toml index 0acd885..450cc1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "firm-server" -version = "0.1.0" +version = "0.1.1" description = "Starlette-based HTTP Server for the FIRM project" authors = ["Steve Bate "] readme = "README.md" @@ -30,7 +30,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -firm = "firm_server.cli:cli" +firm = "firm_server.cli.main:cli" [tool.pytest.ini_options] asyncio_mode = "auto"