moved markdown rendering from Util to MarkdownApi
Build Docker Image / Docker-Build (push) Successful in 2m44s
Build Docker Image / Clean-Registry (push) Successful in 6s

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
2026-05-19 10:46:28 +02:00
parent aa48bbcbf5
commit 8b139b1bed
7 changed files with 118 additions and 109 deletions
@@ -63,8 +63,6 @@ public class Application {
var port = config.get("umbrella.http.port", 8080); var port = config.get("umbrella.http.port", 8080);
var threads = config.get("umbrella.threads", 16); var threads = config.get("umbrella.threads", 16);
config.get("umbrella.plantuml").map(Object::toString).map(File::new).filter(File::exists).ifPresent(Util::setPlantUmlJar);
var server = HttpServer.create(new InetSocketAddress(port), 0); var server = HttpServer.create(new InetSocketAddress(port), 0);
try { try {
new Translations(config).bindPath("/api/translations").on(server); new Translations(config).bindPath("/api/translations").on(server);
@@ -1,7 +1,6 @@
/* © SRSoftware 2025 */ /* © SRSoftware 2025 */
package de.srsoftware.umbrella.core; package de.srsoftware.umbrella.core;
import de.srsoftware.umbrella.core.api.*; import de.srsoftware.umbrella.core.api.*;
public class ModuleRegistry { public class ModuleRegistry {
@@ -7,6 +7,7 @@ import static de.srsoftware.tools.PathHandler.GET;
import static de.srsoftware.tools.PathHandler.POST; import static de.srsoftware.tools.PathHandler.POST;
import static de.srsoftware.tools.Strings.hex; import static de.srsoftware.tools.Strings.hex;
import static de.srsoftware.umbrella.core.Errors.INVALID_URL; import static de.srsoftware.umbrella.core.Errors.INVALID_URL;
import static de.srsoftware.umbrella.core.ModuleRegistry.markdownService;
import static de.srsoftware.umbrella.core.constants.Constants.TIME_FORMATTER; import static de.srsoftware.umbrella.core.constants.Constants.TIME_FORMATTER;
import static de.srsoftware.umbrella.core.constants.Field.*; import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.serverError; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.serverError;
@@ -36,13 +37,8 @@ import org.json.JSONObject;
public class Util { public class Util {
public static final System.Logger LOG = System.getLogger("Util"); public static final System.Logger LOG = System.getLogger("Util");
private static final Pattern UML_PATTERN = Pattern.compile("@start(\\w+)(.*?)@end(\\1)",Pattern.DOTALL);
private static final Pattern SPREADSHEET_PATTERN = Pattern.compile("@startsheet(.*?)@endsheet",Pattern.DOTALL);
private static File plantumlJar = null;
private static final JParsedown MARKDOWN = new JParsedown();
public static final String SHA1 = "SHA-1"; public static final String SHA1 = "SHA-1";
private static final MessageDigest SHA1_DIGEST; private static final MessageDigest SHA1_DIGEST;
private static final Map<Integer,String> umlCache = new HashMap<>();
private static final String SCRIPT = """ private static final String SCRIPT = """
<script src="http://127.0.0.1:8080/js/jspreadsheet-ce.js"></script> <script src="http://127.0.0.1:8080/js/jspreadsheet-ce.js"></script>
@@ -111,71 +107,11 @@ jspreadsheet(document.getElementById('spreadsheet'), {
public static HashMap<String, Object> mapMarkdown(String source){ public static HashMap<String, Object> mapMarkdown(String source){
var map = new HashMap<String,Object>(); var map = new HashMap<String,Object>();
map.put(SOURCE,source); map.put(SOURCE,source);
map.put(RENDERED,markdown(source)); map.put(RENDERED,markdownService().markdown(source));
return map; return map;
} }
public static String markdown(String source){
if (source == null) return source;
try {
var matcher = SPREADSHEET_PATTERN.matcher(source);
var count = 0;
while (matcher.find()){
count++;
var sheetData = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
source = source.substring(0, start)
+ "<div class=\"spreadsheet\" id=\"spreadsheet-"+count+"\">"
+ sheetData.substring(11,sheetData.length()-10)
+ "</div>"
+ source.substring(end);
matcher = SPREADSHEET_PATTERN.matcher(source);
}
if (plantumlJar != null && plantumlJar.exists()) {
matcher = UML_PATTERN.matcher(source);
while (matcher.find()) {
var uml = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
var umlHash = uml.hashCode();
LOG.log(DEBUG,"Hash of Plantuml code: {0}",umlHash);
var svg = umlCache.get(umlHash);
if (svg != null){
LOG.log(DEBUG,"Serving Plantuml generated SVG from cache…");
source = source.substring(0, start) + svg + source.substring(end);
matcher = UML_PATTERN.matcher(source);
continue;
}
LOG.log(DEBUG,"Cache miss. Generating SVG from plantuml code…");
ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", plantumlJar.getAbsolutePath(), "-tsvg", "-pipe");
var ignored = processBuilder.redirectErrorStream();
var process = processBuilder.start();
try (OutputStream os = process.getOutputStream()) {
os.write(uml.getBytes(UTF_8));
os.flush();
}
try (InputStream is = process.getInputStream()) {
byte[] out = is.readAllBytes();
LOG.log(DEBUG,"Generated SVG. Pushing to cache…");
svg = new String(out, UTF_8);
umlCache.put(umlHash,svg);
source = source.substring(0, start) + svg + source.substring(end);
matcher = UML_PATTERN.matcher(source);
}
}
}
return MARKDOWN.text(source);
} catch (Throwable e){
if (LOG.isLoggable(TRACE)){
LOG.log(TRACE,"Failed to render markdown, input was: \n{0}",source,e);
} else LOG.log(WARNING,"Failed to render markdown. Enable TRACE log level for details.");
return source;
}
}
public static HttpURLConnection open(URL url) throws IOException { public static HttpURLConnection open(URL url) throws IOException {
var conn = (HttpURLConnection) url.openConnection(); var conn = (HttpURLConnection) url.openConnection();
@@ -249,11 +185,6 @@ jspreadsheet(document.getElementById('spreadsheet'), {
return new Hash(hex(bytes),SHA1); return new Hash(hex(bytes),SHA1);
} }
public static void setPlantUmlJar(File file){
LOG.log(INFO,"Using plantuml @ {0}",file.getAbsolutePath());
plantumlJar = file;
}
public static String dateTimeOf(long epochMilis){ public static String dateTimeOf(long epochMilis){
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilis), ZoneId.systemDefault()).format(TIME_FORMATTER); return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilis), ZoneId.systemDefault()).format(TIME_FORMATTER);
} }
@@ -2,4 +2,5 @@
package de.srsoftware.umbrella.core.api; package de.srsoftware.umbrella.core.api;
public interface MarkdownService { public interface MarkdownService {
String markdown(String source);
} }
@@ -91,10 +91,7 @@ public class Poll implements Mappable {
return Map.of( return Map.of(
ID,id, ID,id,
NAME,name, NAME,name,
DESCRIPTION, Map.of( DESCRIPTION, Util.mapMarkdown(description),
SOURCE,description,
RENDERED,Util.markdown(description)
),
STATUS,status STATUS,status
); );
} }
@@ -292,10 +289,7 @@ public class Poll implements Mappable {
ID, id, ID, id,
Field.OWNER, owner.toMap(), Field.OWNER, owner.toMap(),
NAME,name, NAME,name,
Field.DESCRIPTION, Map.of( Field.DESCRIPTION, Util.mapMarkdown(description),
Field.SOURCE,description,
Field.RENDERED,Util.markdown(description)
),
Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)), Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)),
Field.PERMISSION, mapPermissions(), Field.PERMISSION, mapPermissions(),
Field.PRIVATE, isPrivate, Field.PRIVATE, isPrivate,
@@ -3,9 +3,7 @@ package de.srsoftware.umbrella.legacy;
import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.umbrella.core.ModuleRegistry.noteService; import static de.srsoftware.umbrella.core.ModuleRegistry.*;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.Util.markdown;
import static de.srsoftware.umbrella.core.constants.Field.TOKEN; import static de.srsoftware.umbrella.core.constants.Field.TOKEN;
import static de.srsoftware.umbrella.core.constants.Field.URI; import static de.srsoftware.umbrella.core.constants.Field.URI;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidField;
@@ -72,7 +70,7 @@ public class NotesLegacy extends BaseHandler {
new Tag("fieldset") new Tag("fieldset")
.add(new Tag("legend").content(authorName)) .add(new Tag("legend").content(authorName))
.add(new Tag("legend").content(note.timestamp().format(DateTimeFormatter.ISO_DATE_TIME))) .add(new Tag("legend").content(note.timestamp().format(DateTimeFormatter.ISO_DATE_TIME)))
.add(new Tag("div").content(markdown(note.text()))) .add(new Tag("div").content(markdownService().markdown(note.text())))
.addTo(html); .addTo(html);
} }
return sendContent(ex,html.toString(2)); return sendContent(ex,html.toString(2));
@@ -5,8 +5,11 @@ import static de.srsoftware.tools.MimeType.MIME_HTML;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService; import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.constants.Field.BASE_URL; import static de.srsoftware.umbrella.core.constants.Field.BASE_URL;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingField;
import static java.lang.System.Logger.Level.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.xrbpowered.jparsedown.JParsedown;
import de.srsoftware.configuration.Configuration; import de.srsoftware.configuration.Configuration;
import de.srsoftware.tools.Path; import de.srsoftware.tools.Path;
import de.srsoftware.umbrella.core.BaseHandler; import de.srsoftware.umbrella.core.BaseHandler;
@@ -15,22 +18,38 @@ import de.srsoftware.umbrella.core.Util;
import de.srsoftware.umbrella.core.api.MarkdownService; import de.srsoftware.umbrella.core.api.MarkdownService;
import de.srsoftware.umbrella.core.constants.Field; import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern; import java.util.regex.Pattern;
public class MarkdownApi extends BaseHandler implements MarkdownService { public class MarkdownApi extends BaseHandler implements MarkdownService {
private static Pattern ANCHOR = Pattern.compile("(?is)<a\\b[^>]*>.*?</a>"); private static final System.Logger LOG = System.getLogger(MarkdownApi.class.getSimpleName());
private static Pattern HREF = Pattern.compile("(?i)\\bhref\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))"); private static final Pattern PATTERN_ANCHOR = Pattern.compile("(?is)<a\\b[^>]*>.*?</a>");
private static Pattern TARGET = Pattern.compile("(?i)\\btarget\\s*="); private static final Pattern PATTERN_HREF = Pattern.compile("(?i)\\bhref\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))");
private static final Pattern PATTERN_SPREADSHEET = Pattern.compile("@startsheet(.*?)@endsheet",Pattern.DOTALL);
private static final Pattern PATTERN_TARGET = Pattern.compile("(?i)\\btarget\\s*=");
private static final Pattern PATTERN_URL = Pattern.compile("@start(\\w+)(.*?)@end(\\1)",Pattern.DOTALL);
private static final Map<Integer,String> umlCache = new HashMap<>();
private static final JParsedown MARKDOWN = new JParsedown();
private final String baseUrl; private final String baseUrl;
private final File plantumlJar;
public MarkdownApi(Configuration config) { public MarkdownApi(Configuration config) {
super(); super();
Optional<String> baseUrl = config.get(BASE_URL); Optional<String> baseUrl = config.get(BASE_URL);
if (baseUrl.isEmpty()) throw missingField(BASE_URL); if (baseUrl.isEmpty()) throw missingField(BASE_URL);
this.baseUrl = baseUrl.get(); this.baseUrl = baseUrl.get();
plantumlJar = config.get("umbrella.plantuml").map(Object::toString).map(File::new).filter(File::exists).orElse(null);
if (plantumlJar != null) LOG.log(INFO,"Using plant uml @ {}",plantumlJar);
ModuleRegistry.add(this); ModuleRegistry.add(this);
} }
@@ -41,29 +60,98 @@ public class MarkdownApi extends BaseHandler implements MarkdownService {
var user = userService().refreshSession(ex); var user = userService().refreshSession(ex);
if (user.isEmpty()) throw UmbrellaException.forbidden("You must be logged in to use the markdown renderer!"); if (user.isEmpty()) throw UmbrellaException.forbidden("You must be logged in to use the markdown renderer!");
var rendered = Util.markdown(body(ex)); var rendered = markdown(body(ex));
var anchors = ANCHOR.matcher(rendered);
while (anchors.find()){
String anchor = anchors.group();
var urls = HREF.matcher(anchor);
if (!urls.find()) continue; // no url → nothing to do
var href = urls.group(1) != null ? urls.group(1) : (urls.group(2) != null ? urls.group(2) : urls.group(3));
var target = TARGET.matcher(anchor);
if (target.find()) continue; // target already set → leave untouched
if (!href.startsWith("http")) continue; // relative url? good!
if (!href.startsWith(baseUrl)) { // add target
var replacement = "<a target=\"_blank\""+anchor.substring(2);
rendered = rendered.replace(anchor, replacement);
anchors.reset(rendered);
}
}
ex.getResponseHeaders().add(CONTENT_TYPE,MIME_HTML); ex.getResponseHeaders().add(CONTENT_TYPE,MIME_HTML);
return sendContent(ex,rendered); return sendContent(ex,rendered);
} catch (UmbrellaException e){ } catch (UmbrellaException e){
return send(ex,e); return send(ex,e);
} }
} }
public String markdown(String source){
if (source == null || source.isBlank()) return source;
try {
var matcher = PATTERN_SPREADSHEET.matcher(source);
var count = 0;
while (matcher.find()){
LOG.log(DEBUG,"Processing spreadsheet code…");
count++;
var sheetData = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
source = source.substring(0, start)
+ "<div class=\"spreadsheet\" id=\"spreadsheet-"+count+"\">"
+ sheetData.substring(11,sheetData.length()-10)
+ "</div>"
+ source.substring(end);
LOG.log(DEBUG,"Updated markdown with spreadsheet div.");
matcher.reset(source);
}
if (plantumlJar != null && plantumlJar.exists()) {
matcher = PATTERN_URL.matcher(source);
while (matcher.find()) {
var uml = matcher.group(0).trim();
var start = matcher.start(0);
var end = matcher.end(0);
var umlHash = uml.hashCode();
LOG.log(DEBUG,"Hash of Plantuml code: {0}",umlHash);
var svg = umlCache.get(umlHash);
if (svg != null){
LOG.log(DEBUG,"Serving Plantuml generated SVG from cache…");
source = source.substring(0, start) + svg + source.substring(end);
matcher.reset(source);
continue;
}
LOG.log(DEBUG,"Cache miss. Generating SVG from plantuml code…");
ProcessBuilder processBuilder = new ProcessBuilder("java", "-jar", plantumlJar.getAbsolutePath(), "-tsvg", "-pipe");
var ignored = processBuilder.redirectErrorStream();
var process = processBuilder.start();
try (OutputStream os = process.getOutputStream()) {
os.write(uml.getBytes(UTF_8));
os.flush();
}
try (InputStream is = process.getInputStream()) {
byte[] out = is.readAllBytes();
LOG.log(DEBUG,"Generated SVG. Pushing to cache…");
svg = new String(out, UTF_8);
umlCache.put(umlHash,svg);
source = source.substring(0, start) + svg + source.substring(end);
matcher.reset(source);
}
}
}
var rendered = MARKDOWN.text(source);
if (baseUrl == null) return rendered;
var anchors = PATTERN_ANCHOR.matcher(rendered);
while (anchors.find()){
String anchor = anchors.group();
LOG.log(TRACE,"Processing anchor: {}",anchor);
var urls = PATTERN_HREF.matcher(anchor);
if (!urls.find()) continue; // no url → nothing to do
var href = urls.group(1) != null ? urls.group(1) : (urls.group(2) != null ? urls.group(2) : urls.group(3));
LOG.log(TRACE," encountered href = {}",href);
if (!href.startsWith("http")) continue; // relative url? good!
LOG.log(TRACE," {} is not a relative url!",href);
var target = PATTERN_TARGET.matcher(anchor);
if (target.find()) continue; // target already set → leave untouched
LOG.log(TRACE," anchor has no target!");
if (href.startsWith(baseUrl)) continue; // local url → don`t touch
LOG.log(TRACE," {} is not an internal url, adding anchor…",href);
var replacement = "<a target=\"_blank\""+anchor.substring(2);
rendered = rendered.replace(anchor, replacement);
anchors.reset(rendered);
}
return rendered;
} catch (Throwable e){
if (LOG.isLoggable(TRACE)){
LOG.log(TRACE,"Failed to render markdown, input was: \n{0}",source,e);
} else LOG.log(WARNING,"Failed to render markdown. Enable TRACE log level for details.");
return source;
}
}
} }