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.

This commit is contained in:
Yvan 2025-01-27 04:03:27 +00:00
parent 614b3a48dd
commit 5f9f024904
10 changed files with 260 additions and 57 deletions

View file

@ -33,6 +33,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.shell:spring-shell-starter' implementation 'org.springframework.shell:spring-shell-starter'
implementation 'org.postgresql:postgresql' implementation 'org.postgresql:postgresql'
implementation 'org.thymeleaf:thymeleaf-spring5:3.1.2.RELEASE'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test' testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.shell:spring-shell-starter-test' testImplementation 'org.springframework.shell:spring-shell-starter-test'

View file

@ -20,12 +20,8 @@ public class BotController {
@Autowired @Autowired
private BotRepo botRepo; private BotRepo botRepo;
private BotService botService;
@Autowired @Autowired
public BotController(BotService botService) { private BotService botService;
this.botService = botService;
}
@GetMapping("/viewbot") @GetMapping("/viewbot")
public String listAll(Model model) { public String listAll(Model model) {

View file

@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
* Database shenanigans... * Database shenanigans...
*/ */
public interface BotRepo extends JpaRepository<Bot, Long> { public interface BotRepo extends JpaRepository<Bot, Long> {
Bot findByUsername(String username);
} }

View file

@ -25,10 +25,15 @@ public class BotService {
return repo.save(bot); 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<Bot> findAll() { public List<Bot> findAll() {
return repo.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 // whilst the URI structure of the below are up to the implementor we're using Mastodon as a reference

View file

@ -3,6 +3,14 @@ package dev.activitypub.activitypubbot;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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. * 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" ) @RequestMapping( headers = "accept=application/json" )
public class RestHandler { public class RestHandler {
@Autowired
private BotService botServ;
@Autowired
@Qualifier("messageTemplateEngine")
protected SpringTemplateEngine jsonTemplater;
/** /**
* Really just an alias to /user/springbot * Really just an alias to /user/<username>
* TODO: how to auto-map @<user> to /users/<user>
*/ */
@RequestMapping(value = "/@springbot", headers = "accept=application/json") @RequestMapping(value = "/@{username}", produces = "application/activity+json") // content type based on Masto request
public String atactor() { public String atactor(@PathVariable String username) {
return this.actor(); return this.actor( username );
} }
/** /**
* Access the bot/user - ultimately this should check the database for * Access the bot/user
* /user/<user> to pull out the relevant data.
*/ */
@RequestMapping(value = "/users/springbot", headers = "accept=application/json") @RequestMapping(value = "/users/{username}", produces = "application/activity+json") // content type based on Masto request
public String actor() { public String actor(@PathVariable String username) {
// 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"
],
"id": "https://springbot.seth.id.au/users/springbot", Bot bot = botServ.getBotByUsername( username );
"url": "https://springbot.seth.id.au/@springbot", if( bot == null ) {
"inbox": "https://springbot.seth.id.au/users/springbot/inbox", throw new ResponseStatusException( HttpStatus.NOT_FOUND, "These are not the droids you are looking for" );
"type": "Person", }
"preferredUsername": "springbot", // TODO: kludgetown... we should generate JSON programmatically rather than templates
"name": "Spring Bot", final Context ctx = new Context();
"manuallyApproveFollowers": false, ctx.setVariable("bot", bot);
"indexable": false, final String json = jsonTemplater.process("json/actor", ctx);
"published": "2025-01-24T00:00:00Z", return json;
"summary": "<p>A bot written using Java/Spring</p>",
"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-----"
}
}""";
} }
/** /**
* Webfinger the user... * Webfinger the user...
*/ */
@GetMapping("/.well-known/webfinger") @GetMapping(value = "/.well-known/webfinger", produces = "application/jrd+json") // content type based on Masto request
public String webfinger() { public String webfinger(@RequestParam("resource") String resource) {
return """
{
"subject": "acct:springbot@springbot.seth.id.au",
"links": [ // resource should be of the form: acct:<username>@<domain>
{ // so this should be robustly checked, but for now just yoink out the username
"rel": "self", String username = resource.substring(resource.indexOf(":") + 1, resource.indexOf("@"));
"type": "application/activity+json", if ( username == null) {
"href": "https://springbot.seth.id.au/users/springbot" 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;
}
} }

View file

@ -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<SpringResourceTemplateResolver> inTemplateResolvers) {
final SpringTemplateEngine theTemplateEngine = new SpringTemplateEngine();
for (SpringResourceTemplateResolver theTemplateResolver : inTemplateResolvers) {
theTemplateEngine.addTemplateResolver(theTemplateResolver);
theTemplateEngine.addTemplateResolver(thymeRemoteConfig);
}
return theTemplateEngine;
}
}

View file

@ -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<String, Object> 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());
}
}

View file

@ -2,6 +2,7 @@ package dev.activitypub.activitypubbot;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -19,13 +20,13 @@ public class WebHandler {
* handle requests for our "actor" - this presents the web/html view of * handle requests for our "actor" - this presents the web/html view of
* the bot * the bot
*/ */
@RequestMapping("/@springbot") @RequestMapping("/@{username}")
public String atactor() { public String atactor(@PathVariable String username) {
log.info("WebHandler::atactor"); log.info("WebHandler::atactor");
return this.actor(); return this.actor( username );
} }
@RequestMapping("/users/springbot") @RequestMapping("/users/{username}")
public String actor() { public String actor(@PathVariable String username) {
log.info("WebHandler::actor"); log.info("WebHandler::actor");
return "index"; // just flinging out the index page for now return "index"; // just flinging out the index page for now
} }

View file

@ -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": "<p>[(${bot.summary})]</p>",
"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-----"
}
}

View file

@ -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})]"
}
]
}