package de.stadtbote.votian; import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; import java.net.URI; import java.util.*; import java.util.regex.*; import static de.stadtbote.votian.AdminSupport.bad; import static de.stadtbote.votian.LegacyMapping.*; /** Renders newsletter placeholders as data. PHP/JavaScript from templates is never executed. */ @Component class NewsletterTemplate { private final String legacy;private final NewsletterImages images; NewsletterTemplate(@Value("${app.legacy-url:}")String legacy,NewsletterImages images){this.legacy=legacy.replaceAll("/+$","");this.images=images;} static String[] imageParts(String raw){if(raw==null||raw.isBlank())return new String[]{"","","",""};String[] p=raw.split(",",4);String name=p[0].strip();if(!name.matches("[\\p{L}\\p{N}_ .()-]{1,200}\\.(?i:png|jpe?g|gif|webp)")||name.contains(".."))throw bad("Bildname muss eine PNG-, JPG-, GIF- oder WebP-Datei ohne Pfadangabe sein.");for(int i=1;i4000))throw bad("Bildabmessungen müssen zwischen 1 und 4000 liegen.");return new String[]{name,p.length>1?p[1].strip():"",p.length>2?p[2].strip():"",p.length>3?p[3]:""};} static String escape(String s){return HtmlUtils.htmlEscape(s==null?"":s);} String image(String value){if(value.isBlank())return "";String[] p=imageParts(value);String local=images.data(p[0]);String src=local!=null?local:legacy+"/documents/images/"+org.springframework.web.util.UriUtils.encodePathSegment(p[0],java.nio.charset.StandardCharsets.UTF_8);return "\""+escape(p[3])+"\""+(p[1].isBlank()?"":"";} @SuppressWarnings("unchecked") String render(Map rendering){var defs=(List>)rendering.get("definitions");var data=(Map)rendering.get("data");var vals=(Map)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap();for(var d:defs){String type=text(d,"type"),v=vals.getOrDefault(id(d,"id"),"");String output=switch(type){case "image"->image(v);case "checkbox"->v.equals("1")?"✓":"";case "text"->Jsoup.clean(v.replace("\n","
"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("

").append(escape(text(d,"label"))).append("

").append(byKey.get(id(d,"keyId"))).append("
");return clean(out.toString());} var matcher=Pattern.compile("\\[-([0-9]{1,10})(?:,[^\\]]*)?-]").matcher(template);var out=new StringBuffer();while(matcher.find())matcher.appendReplacement(out,Matcher.quoteReplacement(byKey.getOrDefault(Long.parseLong(matcher.group(1)),"")));matcher.appendTail(out);String html=out.toString();if(html.contains("[-"))throw bad("Die Newslettervorlage enthält nicht unterstützte Spezialplatzhalter."); // PHP removes an entire numbered container when its CHK_EMPTY element is empty. for(int pass=0;pass<1000;pass++){String before=html;var check=Pattern.compile("(?i)").matcher(html);var empty=new HashSet();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains(".*?","");if(html.equals(before))break;} html=html.replaceAll("(?is)",""); html=html.replaceAll("(?is)","");return clean(html); } String clean(String html){var doc=Jsoup.parse(html,legacy+"/admin/");doc.select("script,iframe,object,embed,form,input,button,textarea,select,meta,link,base").remove(); // Only display properties used by the PHP newsletter layouts survive; no URLs/imports in CSS. for(var el:doc.select("[style]"))el.attr("style",safeStyle(el.attr("style"))); StringBuilder css=new StringBuilder();for(var el:doc.select("style")){var m=Pattern.compile("([a-zA-Z0-9_.# \\s:>,\\-]+)\\{([^{}]*)}").matcher(el.data());while(m.find())css.append(m.group(1)).append('{').append(safeStyle(m.group(2))).append('}');}doc.select("style").remove(); for(var image:doc.select("img[src]")){String abs=image.absUrl("src");if(abs.startsWith(legacy+"/")&&!legacy.isBlank())image.attr("src",abs);else if(!image.attr("src").matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+"))image.remove();} var safe=Safelist.relaxed().addTags("font","hr").addAttributes(":all","style","class").addAttributes("table","width","cellpadding","cellspacing","border").addAttributes("td","width","height","align","valign","bgcolor").addAttributes("font","color","face","size").addProtocols("img","src","https","http","data"); return ""+Jsoup.clean(doc.body().html(),legacy+"/admin/",safe,new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false)); } static String safeStyle(String css){var allowed=Set.of("color","background-color","font-family","font-size","font-weight","font-style","text-align","text-decoration","line-height","vertical-align","width","height","max-width","border","border-collapse","border-color","border-width","border-style","padding","padding-left","padding-right","padding-top","padding-bottom","margin","margin-left","margin-right","margin-top","margin-bottom","display","float","clear","min-width","min-height","box-sizing","white-space","page-break-before","page-break-after","page-break-inside","break-inside");var out=new StringBuilder();for(String part:css.split(";")){String[] kv=part.split(":",2);if(kv.length==2&&allowed.contains(kv[0].strip().toLowerCase(Locale.ROOT))&&kv[1].matches("[a-zA-Z0-9#.,%() '"+'"'+"-]+")&&!kv[1].toLowerCase(Locale.ROOT).matches(".*(url|expression|javascript|behavior).*"))out.append(kv[0].strip()).append(':').append(kv[1].strip()).append(';');}return out.toString();} }