HelpController.java
package com.taxonomy.shared.controller;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.data.MutableDataSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
/**
* Serves the in-app Help tab: table of contents, rendered Markdown documents,
* and documentation images.
*
* <p>Supports locale-aware document resolution:
* <ol>
* <li>Try {@code docs/{lang}/{docName}.md}</li>
* <li>Fall back to {@code docs/en/{docName}.md}</li>
* </ol>
*/
@Controller
@RequestMapping("/help")
public class HelpController {
private static final Logger log = LoggerFactory.getLogger(HelpController.class);
/** Only allow safe filename characters to prevent path traversal. */
private static final java.util.regex.Pattern SAFE_NAME = java.util.regex.Pattern.compile("^[A-Za-z0-9_-]+$");
/** Only allow safe image filenames (letters, digits, hyphens, underscores, dots). */
private static final java.util.regex.Pattern SAFE_IMAGE = java.util.regex.Pattern.compile("^[A-Za-z0-9_.-]+$");
record DocEntry(String filename, String title, String icon, String audience) {}
/** Known documentation files with their default (English) metadata. */
private static final List<String[]> DOC_METADATA = List.of(
new String[]{"USER_GUIDE", "📖", "help.toc.USER_GUIDE", "help.audience.everyone"},
new String[]{"CONCEPTS", "💡", "help.toc.CONCEPTS", "help.audience.everyone"},
new String[]{"EXAMPLES", "📝", "help.toc.EXAMPLES", "help.audience.everyone"},
new String[]{"FRAMEWORK_IMPORT", "📥", "help.toc.FRAMEWORK_IMPORT", "help.audience.everyone"},
new String[]{"GIT_INTEGRATION", "🔀", "help.toc.GIT_INTEGRATION", "help.audience.developers"},
new String[]{"PREFERENCES", "🎛️", "help.toc.PREFERENCES", "help.audience.admins"},
new String[]{"AI_PROVIDERS", "🤖", "help.toc.AI_PROVIDERS", "help.audience.everyone"},
new String[]{"CONFIGURATION_REFERENCE", "⚙️", "help.toc.CONFIGURATION_REFERENCE", "help.audience.admins"},
new String[]{"API_REFERENCE", "🔌", "help.toc.API_REFERENCE", "help.audience.integrators"},
new String[]{"CURL_EXAMPLES", "💻", "help.toc.CURL_EXAMPLES", "help.audience.integrators"},
new String[]{"ARCHITECTURE", "🏗️", "help.toc.ARCHITECTURE", "help.audience.developers"},
new String[]{"DECISION_PIPELINE", "🔬", "help.toc.DECISION_PIPELINE", "help.audience.developers"},
new String[]{"DEVELOPER_GUIDE", "🛠️", "help.toc.DEVELOPER_GUIDE", "help.audience.developers"},
new String[]{"FEATURE_MATRIX", "📋", "help.toc.FEATURE_MATRIX", "help.audience.developers"},
new String[]{"DEPLOYMENT_GUIDE", "🚀", "help.toc.DEPLOYMENT_GUIDE", "help.audience.devops"},
new String[]{"CONTAINER_IMAGE", "🐳", "help.toc.CONTAINER_IMAGE", "help.audience.devops"},
new String[]{"SECURITY", "🔒", "help.toc.SECURITY", "help.audience.admins"},
new String[]{"DATABASE_SETUP", "🗄️", "help.toc.DATABASE_SETUP", "help.audience.devops"},
new String[]{"DEPLOYMENT_CHECKLIST", "✅", "help.toc.DEPLOYMENT_CHECKLIST", "help.audience.devops"},
new String[]{"OPERATIONS_GUIDE", "📋", "help.toc.OPERATIONS_GUIDE", "help.audience.devops"},
new String[]{"KEYCLOAK_SETUP", "🔑", "help.toc.KEYCLOAK_SETUP", "help.audience.devops"},
new String[]{"KEYCLOAK_MIGRATION", "🔄", "help.toc.KEYCLOAK_MIGRATION", "help.audience.devops"},
new String[]{"AI_TRANSPARENCY", "🔍", "help.toc.AI_TRANSPARENCY", "help.audience.everyone"},
new String[]{"AI_LITERACY_CONCEPT", "🎓", "help.toc.AI_LITERACY_CONCEPT", "help.audience.everyone"},
new String[]{"DATA_PROTECTION", "🛡️", "help.toc.DATA_PROTECTION", "help.audience.admins"},
new String[]{"ACCESSIBILITY", "♿", "help.toc.ACCESSIBILITY", "help.audience.everyone"},
new String[]{"BSI_KI_CHECKLIST", "📜", "help.toc.BSI_KI_CHECKLIST", "help.audience.admins"},
new String[]{"DIGITAL_SOVEREIGNTY", "🏴", "help.toc.DIGITAL_SOVEREIGNTY", "help.audience.admins"},
new String[]{"DEUTSCHLAND_STACK_CONFORMITY", "🇩🇪", "help.toc.DEUTSCHLAND_STACK_CONFORMITY", "help.audience.admins"},
new String[]{"USE_CASE_WISSENSKONSERVIERUNG", "📚", "help.toc.USE_CASE_WISSENSKONSERVIERUNG","help.audience.everyone"},
new String[]{"VERWALTUNGSINTEGRATION", "🏢", "help.toc.VERWALTUNGSINTEGRATION", "help.audience.admins"},
new String[]{"DOCUMENT_IMPORT", "📄", "help.toc.DOCUMENT_IMPORT", "help.audience.everyone"},
new String[]{"UI_GAP_ANALYSIS", "📊", "help.toc.UI_GAP_ANALYSIS", "help.audience.developers"},
new String[]{"WORKSPACE_VERSIONING", "🔄", "help.toc.WORKSPACE_VERSIONING", "help.audience.everyone"},
new String[]{"REPOSITORY_TOPOLOGY", "🔗", "help.toc.REPOSITORY_TOPOLOGY", "help.audience.developers"},
new String[]{"RELATION_SEEDS", "🌱", "help.toc.RELATION_SEEDS", "help.audience.developers"}
);
/** Set of known doc filenames for validation. */
static final List<String> KNOWN_FILENAMES = DOC_METADATA.stream()
.map(m -> m[0])
.toList();
private final Parser parser;
private final HtmlRenderer renderer;
private final MessageSource messageSource;
/** Cache key format: "locale:docName", e.g. "de:USER_GUIDE". */
private final ConcurrentHashMap<String, String> htmlCache = new ConcurrentHashMap<>();
public HelpController(MessageSource messageSource) {
this.messageSource = messageSource;
MutableDataSet options = new MutableDataSet();
options.set(Parser.EXTENSIONS, Arrays.asList(
TablesExtension.create(),
AutolinkExtension.create(),
StrikethroughExtension.create()
));
this.parser = Parser.builder(options).build();
this.renderer = HtmlRenderer.builder(options).build();
}
/** Returns the ordered table of contents as JSON, with locale-resolved titles. */
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public List<DocEntry> getToc() {
Locale locale = LocaleContextHolder.getLocale();
return DOC_METADATA.stream()
.map(m -> new DocEntry(
m[0],
messageSource.getMessage(m[2], null, m[0], locale),
m[1],
messageSource.getMessage(m[3], null, "Everyone", locale)))
.toList();
}
/** Renders a Markdown document to HTML, with locale-aware resolution. */
@GetMapping(value = "/{docName}", produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public ResponseEntity<String> getDoc(@PathVariable String docName) {
if (!SAFE_NAME.matcher(docName).matches()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid document name");
}
// Only allow documents that exist in the known list
boolean knownDoc = KNOWN_FILENAMES.contains(docName);
if (!knownDoc) {
return ResponseEntity.notFound().build();
}
Locale locale = LocaleContextHolder.getLocale();
String cacheKey = locale.getLanguage() + ":" + docName;
String html = htmlCache.computeIfAbsent(cacheKey, k -> renderDoc(docName, locale));
if (html == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(html);
}
/** Serves images from classpath docs/images/. */
@GetMapping("/images/{imageName}")
@ResponseBody
public ResponseEntity<byte[]> getImage(@PathVariable String imageName) {
// Validate the full filename: only alphanumeric, hyphens, underscores, and dots allowed.
// This prevents path traversal (no slashes, no "..") while allowing extensions like .png.
if (!SAFE_IMAGE.matcher(imageName).matches() || imageName.contains("..")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
try {
ClassPathResource resource = new ClassPathResource("docs/images/" + imageName);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
byte[] bytes = resource.getInputStream().readAllBytes();
MediaType mediaType = guessMediaType(imageName);
return ResponseEntity.ok().contentType(mediaType).body(bytes);
} catch (IOException e) {
log.warn("Could not read image {}: {}", imageName, e.getMessage());
return ResponseEntity.notFound().build();
}
}
// ── private helpers ───────────────────────────────────────────────────────
/**
* Resolves a document by locale, falling back through:
* docs/{lang}/{docName}.md → docs/en/{docName}.md
*/
private String renderDoc(String docName, Locale locale) {
// 1. Try locale-specific path
String localePath = "docs/" + locale.getLanguage() + "/" + docName + ".md";
ClassPathResource localeResource = new ClassPathResource(localePath);
if (localeResource.exists()) {
return parseResource(localeResource, docName);
}
// 2. Fallback to English
String enPath = "docs/en/" + docName + ".md";
ClassPathResource enResource = new ClassPathResource(enPath);
if (enResource.exists()) {
return parseResource(enResource, docName);
}
return null;
}
private String parseResource(ClassPathResource resource, String docName) {
try (InputStream in = resource.getInputStream()) {
String markdown = new String(in.readAllBytes(), StandardCharsets.UTF_8);
// Rewrite relative image paths to absolute /help/images/... URLs
markdown = markdown.replaceAll("\\(\\.\\./images/([^)]++)\\)", "(/help/images/$1)");
// Also rewrite HTML <img src="../images/..."> tags
markdown = markdown.replaceAll("src=\"\\.\\./images/([^\"]++)\"", "src=\"/help/images/$1\"");
Node document = parser.parse(markdown);
String body = renderer.render(document);
return "<div class=\"help-doc-content\">" + body + "</div>";
} catch (IOException e) {
log.error("Failed to render help doc {}: {}", docName, e.getMessage());
return null;
}
}
private MediaType guessMediaType(String imageName) {
String lower = imageName.toLowerCase();
if (lower.endsWith(".png")) return MediaType.IMAGE_PNG;
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG;
if (lower.endsWith(".gif")) return MediaType.IMAGE_GIF;
if (lower.endsWith(".svg")) return MediaType.valueOf("image/svg+xml");
if (lower.endsWith(".webp")) return MediaType.valueOf("image/webp");
return MediaType.APPLICATION_OCTET_STREAM;
}
}