FediPhoto III

En esta serie de artículos voy a explicar cómo funciona "FediPhoto", una aplicación Java hecha desde cero y (casi) sin dependencias que se integra en el Fediverso, a.k.a. Mastodon usando el protocolo ActivityPub

En este tercer post vamos a centrarnos en implementar un endpoint que permita a cualquier instancia del fediverso conocer los detalles de los usuarios de la nuestra

FediPhoto Core

Como ya vimos en el anterior post, FediPhoto no usa ninguna base de datos y simplemente usa las carpetas que se creen manualmente a partir de una carpeta configurada como root. Cada carpeta representa el nombre de un usuario y al arrancar la aplicación comprueba si el usuario tiene un par de claves generadas

INFO

Esta aplicación es un ejemplo y NO se deberían dejar las claves de forma tan accesible

Webfinger

"WebFinger es un protocolo especificado por el IETF para el descubrimiento de información sobre personas y entes. La información puede ser descubierta por medio de un URI "acct:", que es un URI similar a una dirección de correo electrónico."
https://es.wikipedia.org/wiki/WebFinger

Básicamente es un endpoint HTTP que devuelve un json con un formato específico sobre el usuario "acct" sobre el que se consulta

WebfingerController

Como FediPhoto es una aplicación super simple implementa Webfinger en un simple Controller

src/main/java/com/incsteps/fediphoto/activitypub/WebfingerController.java
@Controller("/.well-known/webfinger")
public class WebfingerController {

    private final String domain;
    private final UsersRepository usersRepository;

    public WebfingerController(@Value("${fediphoto.domain}") String domain, UsersRepository usersRepository) {
        this.domain = domain;
        this.usersRepository = usersRepository;
    }

    @Get
    public HttpResponse<?> webfinger(@QueryValue("resource") String resource) {
        if (resource == null || !resource.startsWith("acct:")) {
            return HttpResponse.badRequest();
        }

        String username = resource.replace("acct:", "").split("@")[0];

        var user = usersRepository.getUser(username);
        if (user == null) {
            return HttpResponse.notFound();
        }

        var selfLink = new WebfingerLink(
                "self",
                "application/activity+json",
                "https://" + domain + "/users/" + username
        );

        return HttpResponse.ok(new WebfingerResponse(
                resource,
                List.of(selfLink)
        ));
    }
}

Usamos un par de DTO WebfingerResponse y WebfingerLink para que Micronaut lo renderize como JSON

src/main/java/com/incsteps/fediphoto/activitypub/model/WebfingerLink.java
@Serdeable
public record WebfingerLink(
        String rel,
        String type,
        String href
) {}
src/main/java/com/incsteps/fediphoto/activitypub/model/WebfingerResponse.java
@Serdeable
public record WebfingerResponse(
        String subject,
        List<WebfingerLink> links) {
}

Ejemplo

Así si nuestra instancia tien un usuario demo (representado como una carpeta "demo" bajo la carpeta definida como raiz) podremos consultar los detalles del usuario:

obtendremos un JSON similar a:

{
  "subject": "acct:demo@fediphoto.jagedn.dev",
  "links": [
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://fediphoto.jagedn.dev/users/demo"
    }
  ]
}

Como puedes observar "links" es un array de enlaces, cada uno con un "type". Las instancias que quieren consultar información sobre nuestros usuarios filtrarán buscando el "type" "application/activity+json" y de ahí obtendrán la URL del usuario

UsersController

Por último nos toca implementar un controller que devuelve la información específica del usuario.

El controller en este caso es tan simple como buscar el usuario que se solicita en la url en nuestra "base de datos" y devolver un "Actor" con los detalles del usuario, como el nombre, la URL a su inbox y su clave pública:

src/main/java/com/incsteps/fediphoto/activitypub/UsersController.java
@Controller("/users")
public class UsersController {

    private final String domain;
    private final UsersRepository usersRepository;

    public UsersController(@Value("${fediphoto.domain}") String domain, UsersRepository usersRepository) {
        this.domain = domain;
        this.usersRepository = usersRepository;
    }

    @Get(value = "/{username}", produces = "application/activity+json")
    public HttpResponse<?> getActor(String username) {

        var user = usersRepository.getUser(username);
        if (user == null) {
            return HttpResponse.notFound();
        }
        var publicKeyRecord = new PublicKeyRecord(
                "https://" + domain + "/users/" + username + "#main-key",
                "https://" + domain + "/users/" + username,
                user.pubKey()
        );
        var actor = new Actor(
                "https://www.w3.org/ns/activitystreams",
                "Person",
                "https://" + domain + "/users/" + username,
                username,
                "https://" + domain + "/users/" + username + "/inbox",
                "https://" + domain + "/users/" + username + "/outbox",
                publicKeyRecord);

        return HttpResponse.ok(actor);
    }

}

Actor y PublicKeyRecord son a su vez dos DTO que nos sirven para que Micronaut renderize el JSON correcto

src/main/java/com/incsteps/fediphoto/activitypub/model/PublicKeyRecord.java
@Serdeable
public record PublicKeyRecord(
String id,
String owner,
String publicKeyPem
) {}
src/main/java/com/incsteps/fediphoto/activitypub/model/Actor.java
@Serdeable
public record Actor(
    @JsonProperty("@context") Object context, // Puede ser String o List
    String type,
    String id,
    String preferredUsername,
    String inbox,
    String outbox,
    PublicKeyRecord publicKey
) {}

Simplemente, remarcar que el atributo "context" debe renderizarse como "@context" y por eso usamos JsonProperty

Ejemplo

Así nuestra aplicación es capaz de proporcionar información al exterior y devolvería algo como:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Person",
  "id": "https://fediphoto.jagedn.dev/users/demo",
  "preferredUsername": "demo",
  "inbox": "https://fediphoto.jagedn.dev/users/demo/inbox",
  "outbox": "https://fediphoto.jagedn.dev/users/demo/outbox",
  "publicKey": {
    "id": "https://fediphoto.jagedn.dev/users/demo#main-key",
    "owner": "https://fediphoto.jagedn.dev/users/demo",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...ePIOfL7ZLOR\n/QIDAQAB\n-----END PUBLIC KEY-----"
  }
}

Con esta información "mínima" una instancia remota puede por un lado mostrar el perfil del usuario "demo" así como tener la información necesaria para interactuar con él (a través del endpoint "inbox") y validar los mensajes que este usuario emita (validando la firma usando la publicKeyPem)

Este texto ha sido escrito por un humano

This post has been written by a human

2019 - 2026 | Mixed with Bootstrap | Baked with JBake v2.6.7 | Terminos Terminos y Privacidad