Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen

Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

@@ -0,0 +1,37 @@
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;i<Math.min(3,p.length);i++)if(!p[i].isBlank()&&(!p[i].strip().matches("[1-9][0-9]{0,3}")||Integer.parseInt(p[i].strip())>4000))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 "<img src=\""+escape(src)+"\" alt=\""+escape(p[3])+"\""+(p[1].isBlank()?"":" width=\""+p[1]+"\"")+(p[2].isBlank()?"":" height=\""+p[2]+"\"")+">";}
@SuppressWarnings("unchecked") String render(Map<String,Object> rendering){var defs=(List<Map<String,Object>>)rendering.get("definitions");var data=(Map<String,Object>)rendering.get("data");var vals=(Map<Long,String>)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap<Long,String>();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","<br>"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("<h3>").append(escape(text(d,"label"))).append("</h3><div>").append(byKey.get(id(d,"keyId"))).append("</div>");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)<CHK_EMPTY_([0-9]+)>").matcher(html);var empty=new HashSet<String>();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("</CHK_EMPTY_"+check.group(1)+">",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains("<img"))empty.add(check.group(1));}}for(String n:empty)html=html.replaceAll("(?is)<REMOVE_EMPTY_"+n+">.*?</REMOVE_EMPTY_"+n+">","");if(html.equals(before))break;}
html=html.replaceAll("(?is)</?CHK_EMPTY_[0-9]+>","");
html=html.replaceAll("(?is)</?REMOVE_EMPTY_[0-9]+>","");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 "<style>body{font-family:Arial,sans-serif;margin:20px}img{max-width:100%;height:auto}"+css+"</style>"+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();}
}