package com.incsteps.fediphoto.data;
public record User(
String username,
String pubKey,
String privKey) {
}
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 segundo post vamos a centrarnos en la parte "pura" de la aplicación sin llegar a entrar en las funcionalidades de ActivityPub para poder tener un "producto" sobre el que trabajar
Parte 1: Introducción fediphoto-i.html
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
Esta aplicación es un ejemplo y NO se deberían dejar las claves de forma tan accesible
Siguiendo las buenas prácticas, aunque la aplicación no use una base de datos vamos a crear un User y un UserRepository que nos sirvan para abstraer la funcionalidad
package com.incsteps.fediphoto.data;
public record User(
String username,
String pubKey,
String privKey) {
}
package com.incsteps.fediphoto.data;
import java.util.List;
public interface UsersRepository {
List<User> getUsers();
User getUser(String username);
void setKey(String username, String publicKey, String privateKey);
void addFollower(User user, String followerUrl);
void removeFollower(User user, String followerUrl);
List<String> getFollowerUrls(String username);
}
y la implementación basada en fichero
@Singleton
public class FileUsersRepository implements UsersRepository {
private final String usersDir;
public FileUsersRepository(@Value("${fediphoto.users}") String usersDir) throws IOException {
this.usersDir = usersDir;
Files.createDirectories(Path.of(usersDir));
}
public List<User> getUsers() {
return Arrays.stream(
Objects.requireNonNull(new File(this.usersDir).listFiles(File::isDirectory))
)
.map(this::fromDir)
.toList();
}
@Override
public User getUser(String username) {
var dir = new File(this.usersDir, username);
if(!dir.exists()) return null;
return fromDir(dir);
}
@Override
public void setKey(String username, String publicKey, String privateKey) {
saveFile(username, "public.key", publicKey);
saveFile(username, "private.key", privateKey);
}
@Override
public synchronized void addFollower(User user, String followerUrl) {
var followers = readFile(user.username(), "followers.txt");
if( followers == null) followers = "";
followers += followerUrl+"\n";
saveFile(user.username(), "followers.txt", followers+"\n");
}
@Override
public synchronized void removeFollower(User user, String followerUrl) {
var followers = readFile(user.username(), "followers.txt");
if( followers == null) followers = "";
var list = Arrays.stream(followers.split("\n")).filter(f -> !f.equals(followerUrl)).toList();
saveFile(user.username(), "followers.txt", String.join("\n", list));
}
@Override
public List<String> getFollowerUrls(String username) {
var user = getUser(username);
if( user == null) return List.of();
var followers = readFile(user.username(), "followers.txt");
return List.of(followers.split("\n"));
}
private User fromDir(File dir) {
var publicKey = getPublicKey(dir.getName());
var privKey = getPrivateKey(dir.getName());
return new User(dir.getName(), publicKey, privKey);
}
protected String getPublicKey(String username) {
return readFile(username, "public.key");
}
protected String getPrivateKey(String username) {
return readFile(username, "private.key");
}
protected String readFile(String username, String fileName) {
var file = new File(usersDir+File.separator+username, fileName);
if (!file.exists()) {
return null;
}
try {
return String.join("\n", Files.readAllLines(file.toPath()));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
protected void saveFile(String username, String fileName, String content) {
var file = new File(usersDir+File.separator+username, fileName);
try {
Files.write(file.toPath(), content.getBytes());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Como se puede ver el repository usa una carpeta root y para cada usuario maneja un fichero de
texto followers.txt y un par public.key private.key
Para generar las claves de un usuario vamos a usar un Singleton sencillo:
@Singleton
public class CryptService implements ApplicationEventListener<StartupEvent> {
private final UsersRepository usersRepository;
public CryptService(UsersRepository usersRepository) {
this.usersRepository = usersRepository;
}
@Override
public void onApplicationEvent(StartupEvent event) {
for(var user : usersRepository.getUsers()){
if( user.privKey()==null || user.pubKey()==null){
var keys = generateUserKeyPair();
usersRepository.setKey(user.username(), keys[0], keys[1]);
};
}
}
private String[] generateUserKeyPair() {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
String[] result = new String[2];
result[0] = formatKey(kp.getPublic().getEncoded(), "PUBLIC");
result[1] = formatKey(kp.getPrivate().getEncoded(), "PRIVATE");
return result;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Error: No se encontró el algoritmo RSA", e);
}
}
private String formatKey(byte[] encoded, String type) {
String base64 = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded);
return String.format("-----BEGIN %s KEY-----\n%s\n-----END %s KEY-----",
type, base64, type);
}
}
Es un singleton que al levantar la aplicación busca los usuarios y genera un par de claves para los que no las tengan creadas todavia
Usa clases incluídas en el JDK estándar para crear claves RSA
La lógica de negocio del aplicativo es bien simple. Escanea cada X segundos las carpetas de los usuarios y si existe alguna foto nueva publicará una "activity" en la red del usuario (una notificación a cada follower)
@Singleton
public class ImageService {
private static final Logger LOG = LoggerFactory.getLogger(ImageService.class);
private final String usersDir;
private final UsersRepository usersRepository;
private final FederationService federationService;
ImageService(@Value("${fediphoto.users}") String usersDir,
FederationService federationService,
UsersRepository usersRepository) {
this.usersDir = usersDir;
this.usersRepository = usersRepository;
this.federationService = federationService;
}
@Scheduled(fixedDelay = "30s")
void scanForImages() {
for (var user : usersRepository.getUsers()) {
scanForUserImages(user);
}
}
void scanForUserImages(User user) {
File inputDir = new File(usersDir+File.separator+user.username(), "input");
if (!inputDir.exists()) inputDir.mkdirs();
File[] files = inputDir.listFiles((dir, name) ->
name.toLowerCase().endsWith(".png") || name.toLowerCase().endsWith(".jpg"));
if (files != null) {
for (File file : files) {
publishAndProcess(user.username(), file);
}
}
}
private void publishAndProcess(String username, File file) {
try {
LOG.info("Imagen detectada: {}", file.getName());
File processedDir = new File(usersDir+File.separator+username, "processed");
if (!processedDir.exists()) processedDir.mkdirs();
String now = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date());
File dest = new File(processedDir + "/" + now + ".png");
Files.move(file.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
publishToFediverse(username, dest);
LOG.info("Imagen procesada y movida a: {}", dest.getPath());
} catch (Exception e) {
LOG.error("Error procesando {}", file.getName(), e);
}
}
private void publishToFediverse(String username, File file) {
federationService.sendNewPhoto(username, file.getName());
}
public byte[]getImage(String username, String image) throws IOException {
File processedDir = new File(usersDir+File.separator+username, "processed");
if (!processedDir.exists()) processedDir.mkdirs();
File dest = new File(processedDir + "/" + image);
if( !dest.exists()){
return null;
}
return Files.readAllBytes(dest.toPath());
}
}
El servicio tiene una dependencia con FederationService que aún no hemos visto
Como se puede ver el servicio es super sencillo y lo único que hace es para cada imagen encontrada la mueve de "input" a "processed" para poder ser vista via navegador.
Una vez ejecutado la "lógica de negocio" se invoca al federationService para que notifique a los followers
Para que un usuario externo pueda ver las imagenes de los usuarios del aplicativo vamos a crear un Controller
@Controller("/images")
public class ImageController {
private final ImageService imageService;
public ImageController(ImageService imageService) {
this.imageService = imageService;
}
@Get("{username}/{image}")
@Produces(MediaType.IMAGE_PNG)
public HttpResponse<?> getImage(String username, String image) {
try{
var bytes = imageService.getImage(username,image);
return HttpResponse.ok(bytes);
}catch (Exception e){
return HttpResponse.notFound();
}
}
}
En una aplicación más completa el UI será más complejo, a lo mejor una aplicación cliente javascript llamando a un API Rest, o usando Views de Micronaut crear un interface completo
En nuestro caso el UI es simplemente un endpoint que recibe un Http Get "/{username}{id}" para buscar la foto a mostrar
2019 - 2026 | Mixed with Bootstrap | Baked with JBake v2.6.7 | Terminos Terminos y Privacidad