From 5f9f02490492ee1fb6199486a4d06dcdd6b9fb5a Mon Sep 17 00:00:00 2001 From: Yvan Date: Mon, 27 Jan 2025 04:03:27 +0000 Subject: [PATCH] Now generating templated JSON actor and webfinger responses, which work against a Mastodon user search... the borrowed Thymeleaf config stuff to enable JSON templating has broken the HTML templating though. Sigh. --- Java/Spring/activitypubbot/build.gradle | 1 + .../activitypubbot/BotController.java | 6 +- .../activitypub/activitypubbot/BotRepo.java | 2 +- .../activitypubbot/BotService.java | 5 + .../activitypubbot/RestHandler.java | 95 +++++++++--------- .../activitypubbot/ThymeleafConf.java | 99 +++++++++++++++++++ .../ThymeleafRemoteResourceResolver.java | 63 ++++++++++++ .../activitypubbot/WebHandler.java | 11 ++- .../main/resources/templates/json/actor.json | 23 +++++ .../resources/templates/json/webfinger.json | 12 +++ 10 files changed, 260 insertions(+), 57 deletions(-) create mode 100644 Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafConf.java create mode 100644 Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafRemoteResourceResolver.java create mode 100644 Java/Spring/activitypubbot/src/main/resources/templates/json/actor.json create mode 100644 Java/Spring/activitypubbot/src/main/resources/templates/json/webfinger.json diff --git a/Java/Spring/activitypubbot/build.gradle b/Java/Spring/activitypubbot/build.gradle index 1d68338..0a4dc7d 100644 --- a/Java/Spring/activitypubbot/build.gradle +++ b/Java/Spring/activitypubbot/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.shell:spring-shell-starter' implementation 'org.postgresql:postgresql' + implementation 'org.thymeleaf:thymeleaf-spring5:3.1.2.RELEASE' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.springframework.shell:spring-shell-starter-test' diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotController.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotController.java index e59f7ab..b0c7fc2 100644 --- a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotController.java +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotController.java @@ -20,12 +20,8 @@ public class BotController { @Autowired private BotRepo botRepo; - private BotService botService; - @Autowired - public BotController(BotService botService) { - this.botService = botService; - } + private BotService botService; @GetMapping("/viewbot") public String listAll(Model model) { diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotRepo.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotRepo.java index b662a1f..999ba05 100644 --- a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotRepo.java +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotRepo.java @@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository; * Database shenanigans... */ public interface BotRepo extends JpaRepository { - + Bot findByUsername(String username); } diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotService.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotService.java index ca67746..1f493fb 100644 --- a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotService.java +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/BotService.java @@ -25,10 +25,15 @@ public class BotService { return repo.save(bot); } + // so far these methods just being calls to repo make me wonder why we need a service in the first place... public List findAll() { return repo.findAll(); } + public Bot getBotByUsername( String username ) { + return repo.findByUsername( username ); + } + // whilst the URI structure of the below are up to the implementor we're using Mastodon as a reference diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/RestHandler.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/RestHandler.java index c0548c2..44f9d19 100644 --- a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/RestHandler.java +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/RestHandler.java @@ -3,6 +3,14 @@ package dev.activitypub.activitypubbot; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; /** * Here we handle any JSON/REST requests, which is how ActivityPub instances talk to each other. @@ -11,63 +19,58 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping( headers = "accept=application/json" ) public class RestHandler { + @Autowired + private BotService botServ; + + @Autowired + @Qualifier("messageTemplateEngine") + protected SpringTemplateEngine jsonTemplater; + /** - * Really just an alias to /user/springbot - * TODO: how to auto-map @ to /users/ + * Really just an alias to /user/ */ - @RequestMapping(value = "/@springbot", headers = "accept=application/json") - public String atactor() { - return this.actor(); + @RequestMapping(value = "/@{username}", produces = "application/activity+json") // content type based on Masto request + public String atactor(@PathVariable String username) { + return this.actor( username ); } /** - * Access the bot/user - ultimately this should check the database for - * /user/ to pull out the relevant data. + * Access the bot/user */ - @RequestMapping(value = "/users/springbot", headers = "accept=application/json") - public String actor() { - // TODO: this needs some sort of object wrapper or template, and data in database - return """ -{ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ], + @RequestMapping(value = "/users/{username}", produces = "application/activity+json") // content type based on Masto request + public String actor(@PathVariable String username) { - "id": "https://springbot.seth.id.au/users/springbot", - "url": "https://springbot.seth.id.au/@springbot", - "inbox": "https://springbot.seth.id.au/users/springbot/inbox", - "type": "Person", - "preferredUsername": "springbot", - "name": "Spring Bot", - "manuallyApproveFollowers": false, - "indexable": false, - "published": "2025-01-24T00:00:00Z", - "summary": "

A bot written using Java/Spring

", - "publicKey": { - "id": "https://springbot.seth.id.au/users/springbot#main-key", - "owner": "https://springap.seth.id.au/users/springbot", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0A5W6M6b+3meJAU0/Fki\\nkMSrEZ6EEThAv4NmyCeDlRbmFQWbh5rWtb69TxkGkkiSFNM3sgg+RSW44Ehn10mL\\nTptfk6oSWFnFHw9MPxmwlWm1Xw8zmp2OMUlI82w11PECFdITJw/1HW73JSVQYfFq\\nWo9rD6nI9G3LPpAB16015NJ9hyeMvz5RA9p9UE540q0l5iJD/l7bxCjHglOQInQX\\neCiR2ErzQSVq3AMhBehoP7HuhKjs8swi8dOgjO3sawqxUyv2+lkesFD2rvxCcXRO\\nBkg/Y7nmJSEcqtcmKYQdObPCIt/wCZNAihJz7dwnGKLE2+JJqPZMer9fAj077OkQ\\neQIDAQAB\\n-----END PUBLIC KEY-----" - } -}"""; + Bot bot = botServ.getBotByUsername( username ); + if( bot == null ) { + throw new ResponseStatusException( HttpStatus.NOT_FOUND, "These are not the droids you are looking for" ); + } + // TODO: kludgetown... we should generate JSON programmatically rather than templates + final Context ctx = new Context(); + ctx.setVariable("bot", bot); + final String json = jsonTemplater.process("json/actor", ctx); + return json; } /** * Webfinger the user... */ - @GetMapping("/.well-known/webfinger") - public String webfinger() { - return """ -{ - "subject": "acct:springbot@springbot.seth.id.au", + @GetMapping(value = "/.well-known/webfinger", produces = "application/jrd+json") // content type based on Masto request + public String webfinger(@RequestParam("resource") String resource) { - "links": [ - { - "rel": "self", - "type": "application/activity+json", - "href": "https://springbot.seth.id.au/users/springbot" - } - ] -}"""; - } + // resource should be of the form: acct:@ + // so this should be robustly checked, but for now just yoink out the username + String username = resource.substring(resource.indexOf(":") + 1, resource.indexOf("@")); + if ( username == null) { + throw new ResponseStatusException( HttpStatus.NOT_FOUND, "Malformed" ); + } + Bot bot = botServ.getBotByUsername( username ); + if( bot == null ) { + throw new ResponseStatusException( HttpStatus.NOT_FOUND, "These are not the droids you are looking for" ); + } + // TODO: kludgetown... we should generate JSON programmatically rather than templates + final Context ctx = new Context(); + ctx.setVariable("bot", bot); + final String json = jsonTemplater.process("json/webfinger", ctx); + return json; + } } diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafConf.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafConf.java new file mode 100644 index 0000000..bef2e5b --- /dev/null +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafConf.java @@ -0,0 +1,99 @@ +package dev.activitypub.activitypubbot; +// Derived from: https://github.com/RAJNISH3/ThymeLeafApp/blob/master/src/main/java/com/sample/thymeleaf/ThymeleafConfiguration.java + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.thymeleaf.spring5.SpringTemplateEngine; +import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver; + +@Configuration +@EnableAutoConfiguration +public class ThymeleafConf { + + /** + * Path to root package/directory in which the different types of message templates are found. + */ + public static final String TEMPLATES_BASE = "classpath:/templates/"; + /** Pattern relative to templates base used to match JSON templates. */ + public static final String JSON_TEMPLATES_RESOLVE_PATTERN = "json/*"; + public static final String HTML_TEMPLATES_RESOLVE_PATTERN = "html/*"; + + /** + * Creates the template resolver that retrieves text message payloads. + * + * @return Template resolver. + */ + @Autowired + ThymeleafRemoteResourceResolver thymeRemoteConfig; + + + /** + * Creates the template resolver that retrieves JSON message payloads. + * + * @return Template resolver. + */ + @Bean + public SpringResourceTemplateResolver jsonMessageTemplateResolver() { + SpringResourceTemplateResolver theResourceTemplateResolver = new SpringResourceTemplateResolver(); + theResourceTemplateResolver.setPrefix(TEMPLATES_BASE); + theResourceTemplateResolver.setResolvablePatterns(Collections.singleton(JSON_TEMPLATES_RESOLVE_PATTERN)); + theResourceTemplateResolver.setSuffix(".json"); + /* + * There is no json template mode so the next line has been commented out. Thymeleaf will recognize the ".json" + * template resource suffix so there is no need to set a template mode. + */ + // theResourceTemplateResolver.setTemplateMode("json"); + theResourceTemplateResolver.setCharacterEncoding("UTF-8"); + theResourceTemplateResolver.setCacheable(false); + theResourceTemplateResolver.setOrder(2); + return theResourceTemplateResolver; + } + + /** + * Creates the template resolver that retrieves JSON message payloads. + * + * @return Template resolver. + */ + @Bean + public SpringResourceTemplateResolver htmlMessageTemplateResolver() { + SpringResourceTemplateResolver theResourceTemplateResolver = new SpringResourceTemplateResolver(); + theResourceTemplateResolver.setPrefix(TEMPLATES_BASE); + theResourceTemplateResolver.setResolvablePatterns(Collections.singleton(HTML_TEMPLATES_RESOLVE_PATTERN)); + theResourceTemplateResolver.setSuffix(".html"); + /* + * There is no json template mode so the next line has been commented out. Thymeleaf will recognize the ".json" + * template resource suffix so there is no need to set a template mode. + */ + // theResourceTemplateResolver.setTemplateMode("json"); + theResourceTemplateResolver.setCharacterEncoding("UTF-8"); + theResourceTemplateResolver.setCacheable(false); + theResourceTemplateResolver.setOrder(2); + return theResourceTemplateResolver; + } + + /** + * Creates the template engine for all message templates. + * + * @param inTemplateResolvers + * Template resolver for different types of messages etc. Note that any template resolvers defined + * elsewhere will also be included in this collection. + * @return Template engine. + */ + @Bean + public SpringTemplateEngine messageTemplateEngine( + final Collection inTemplateResolvers) { + final SpringTemplateEngine theTemplateEngine = new SpringTemplateEngine(); + for (SpringResourceTemplateResolver theTemplateResolver : inTemplateResolvers) { + theTemplateEngine.addTemplateResolver(theTemplateResolver); + theTemplateEngine.addTemplateResolver(thymeRemoteConfig); + } + return theTemplateEngine; + } + +} diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafRemoteResourceResolver.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafRemoteResourceResolver.java new file mode 100644 index 0000000..2897dea --- /dev/null +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/ThymeleafRemoteResourceResolver.java @@ -0,0 +1,63 @@ +package dev.activitypub.activitypubbot; +// From: https://github.com/RAJNISH3/ThymeLeafApp/blob/master/src/main/java/com/sample/thymeleaf/ThymeleafRemoteResourceResolver.java + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.templateresolver.StringTemplateResolver; +import org.thymeleaf.templateresource.ITemplateResource; + +@Service +public class ThymeleafRemoteResourceResolver extends StringTemplateResolver { + + private final static String PREFIX = ""; + + public ThymeleafRemoteResourceResolver() { + setResolvablePatterns(Collections.singleton("*")); + } + + + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate, + String template, Map templateResolutionAttributes) { + + // ThymeleafTemplate is our internal object that contains the content. + // You should change this to match you're set up. + //InputStream inp = this.getClass().getResourceAsStream("/templates/text/personalDetails.txt");//text + InputStream inp = this.getClass().getResourceAsStream("/templates/text/personalDtlsLoop.txt"); + //Thread.currentThread().getContextClassLoader().getResourceAsStream("/templates/text/personalDetails.txt"); + String s1 = null; + try { + s1 = convertInputStreamToString(inp); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + if (inp != null) { + return super.computeTemplateResource(configuration, "txt", s1, + templateResolutionAttributes); + } + return null; + } + private static String convertInputStreamToString(InputStream inputStream) + throws IOException { + + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + + return result.toString(StandardCharsets.UTF_8.name()); + + } +} diff --git a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/WebHandler.java b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/WebHandler.java index fc70673..b5de7de 100644 --- a/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/WebHandler.java +++ b/Java/Spring/activitypubbot/src/main/java/dev/activitypub/activitypubbot/WebHandler.java @@ -2,6 +2,7 @@ package dev.activitypub.activitypubbot; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.beans.factory.annotation.Autowired; import lombok.extern.slf4j.Slf4j; @@ -19,13 +20,13 @@ public class WebHandler { * handle requests for our "actor" - this presents the web/html view of * the bot */ - @RequestMapping("/@springbot") - public String atactor() { + @RequestMapping("/@{username}") + public String atactor(@PathVariable String username) { log.info("WebHandler::atactor"); - return this.actor(); + return this.actor( username ); } - @RequestMapping("/users/springbot") - public String actor() { + @RequestMapping("/users/{username}") + public String actor(@PathVariable String username) { log.info("WebHandler::actor"); return "index"; // just flinging out the index page for now } diff --git a/Java/Spring/activitypubbot/src/main/resources/templates/json/actor.json b/Java/Spring/activitypubbot/src/main/resources/templates/json/actor.json new file mode 100644 index 0000000..356de12 --- /dev/null +++ b/Java/Spring/activitypubbot/src/main/resources/templates/json/actor.json @@ -0,0 +1,23 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + + "id": "https://springbot.seth.id.au/users/[(${bot.username})]", + "url": "https://springbot.seth.id.au/@[(${bot.username})]", + "inbox": "https://springbot.seth.id.au/users/[(${bot.username})]/inbox", + "type": "[(${bot.type})]", + "preferredUsername": "[(${bot.username})]", + "name": "[(${bot.name})]", + "manuallyApproveFollowers": false, + "indexable": false, + "published": "[(${bot.published})]", + "summary": "

[(${bot.summary})]

", + "publicKey": { + "id": "https://springbot.seth.id.au/users/[(${bot.username})]#main-key", + "owner": "https://springbot.seth.id.au/users/[(${bot.username})]", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0A5W6M6b+3meJAU0/Fki\\nkMSrEZ6EEThAv4NmyCeDlRbmFQWbh5rWtb69TxkGkkiSFNM3sgg+RSW44Ehn10mL\\nTptfk6oSWFnFHw9MPxmwlWm1Xw8zmp2OMUlI82w11PECFdITJw/1HW73JSVQYfFq\\nWo9rD6nI9G3LPpAB16015NJ9hyeMvz5RA9p9UE540q0l5iJD/l7bxCjHglOQInQX\\neCiR2ErzQSVq3AMhBehoP7HuhKjs8swi8dOgjO3sawqxUyv2+lkesFD2rvxCcXRO\\nBkg/Y7nmJSEcqtcmKYQdObPCIt/wCZNAihJz7dwnGKLE2+JJqPZMer9fAj077OkQ\\neQIDAQAB\\n-----END PUBLIC KEY-----" + } +} + diff --git a/Java/Spring/activitypubbot/src/main/resources/templates/json/webfinger.json b/Java/Spring/activitypubbot/src/main/resources/templates/json/webfinger.json new file mode 100644 index 0000000..d2a6e60 --- /dev/null +++ b/Java/Spring/activitypubbot/src/main/resources/templates/json/webfinger.json @@ -0,0 +1,12 @@ +{ + "subject": "acct:[(${bot.username})]@springbot.seth.id.au", + + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://springbot.seth.id.au/users/[(${bot.username})]" + } + ] +} +