package jnpf.fileStorage.dms; import jnpf.exception.DataException; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.w3c.dom.*; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.*; import java.net.URI; import java.nio.file.Files; import java.util.*; import java.util.zip.*; /** Cleans a new formal copy, rejecting revisions and leaving the source review attachment intact. */ @Service public class DmsDocxCommentCleaner { private static final int MAX_PARTS = 4096; private static final int MAX_BYTES = 50 * 1024 * 1024; private static final int MAX_XML = 16 * 1024 * 1024; private static final String CT = "http://schemas.openxmlformats.org/package/2006/content-types"; private static final String REL = "http://schemas.openxmlformats.org/package/2006/relationships"; private static final Set COMMENT_TYPES = Set.of("comments", "commentsExtended", "commentsIds", "commentsExtensible", "people"); private static final Set MARKERS = Set.of("commentRangeStart", "commentRangeEnd", "commentReference"); public MultipartFile clean(MultipartFile source) { String name = source.getOriginalFilename(); if (name == null || !name.toLowerCase(Locale.ROOT).endsWith(".docx")) return source; try { Map parts = read(source); Document types = parse(required(parts, "[Content_Types].xml")); Map xml = new LinkedHashMap<>(); Set removed = new HashSet<>(); boolean main = false; NodeList overrides = types.getElementsByTagNameNS(CT, "Override"); for (int i = 0; i < overrides.getLength(); i++) { Element type = (Element) overrides.item(i); String contentType = type.getAttribute("ContentType"); String part = target("", type.getAttribute("PartName")); if (commentContentType(contentType)) removed.add(part); if (contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")) { required(parts, part); main = true; } } if (!main) throw new IOException("Missing DOCX main part"); // Parse every XML part securely. Relationship discovery also handles nonstandard comment part names. for (Map.Entry part : parts.entrySet()) { if (part.getKey().endsWith(".xml") || part.getKey().endsWith(".rels")) { Document doc = parse(part.getValue()); xml.put(part.getKey(), doc); if (part.getKey().endsWith(".rels")) { NodeList links = doc.getElementsByTagNameNS(REL, "Relationship"); for (int i = 0; i < links.getLength(); i++) { Element link = (Element) links.item(i); if (commentRelationship(link.getAttribute("Type")) && !external(link)) removed.add(target(owner(part.getKey()), link.getAttribute("Target"))); } } } } // Remove comment-owned resources only if no retained part references them (shared images stay). boolean expanded; do { expanded = false; Set candidates = new HashSet<>(); Set retainedTargets = new HashSet<>(); for (Map.Entry part : xml.entrySet()) { if (!part.getKey().endsWith(".rels")) continue; String owner = owner(part.getKey()); NodeList links = part.getValue().getElementsByTagNameNS(REL, "Relationship"); for (int i = 0; i < links.getLength(); i++) { Element link = (Element) links.item(i); if (external(link)) continue; String dest = target(owner, link.getAttribute("Target")); if (removed.contains(owner)) candidates.add(dest); else if (!commentRelationship(link.getAttribute("Type"))) retainedTargets.add(dest); } } candidates.removeAll(retainedTargets); if (removed.addAll(candidates)) expanded = true; } while (expanded); for (String part : new ArrayList<>(removed)) removed.add(rels(part)); boolean changed = parts.keySet().removeAll(removed); for (Map.Entry part : xml.entrySet()) { if (removed.contains(part.getKey())) continue; Document doc = part.getValue(); boolean edited = DmsDocxRevisionRejector.reject(doc); edited |= removeMarkers(doc); if (part.getKey().endsWith(".rels")) { NodeList links = doc.getElementsByTagNameNS(REL, "Relationship"); for (int i = links.getLength() - 1; i >= 0; i--) { Element link = (Element) links.item(i); if (commentRelationship(link.getAttribute("Type")) || !external(link) && removed.contains(target(owner(part.getKey()), link.getAttribute("Target")))) { link.getParentNode().removeChild(link); edited = true; } } } if (part.getKey().equals("[Content_Types].xml")) { NodeList entries = doc.getElementsByTagNameNS(CT, "Override"); for (int i = entries.getLength() - 1; i >= 0; i--) { Element entry = (Element) entries.item(i); if (removed.contains(target("", entry.getAttribute("PartName")))) { entry.getParentNode().removeChild(entry); edited = true; } } } if (edited) { parts.put(part.getKey(), serialize(doc)); changed = true; } } if (!changed) return source; // Package signatures cannot survive byte changes. Business signature-page content is unaffected. if (parts.keySet().stream().anyMatch(p -> p.startsWith("_xmlsignatures/"))) throw new IOException("Digitally signed package cannot be rewritten"); ByteArrayOutputStream output = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(output)) { for (Map.Entry part : parts.entrySet()) { zip.putNextEntry(new ZipEntry(part.getKey())); zip.write(part.getValue()); zip.closeEntry(); } } return new CleanedFile(source.getName(), name, source.getContentType(), output.toByteArray()); } catch (Exception e) { throw new DataException("DOCX修订及批注清理失败,正式文件未存储:" + e.getMessage()); } } private static boolean commentContentType(String type) { for (String name : COMMENT_TYPES) { if (type.equals("application/vnd.openxmlformats-officedocument.wordprocessingml." + name + "+xml") || type.equals("application/vnd.ms-word." + name + "+xml")) return true; } return false; } private static boolean commentRelationship(String type) { return (type.startsWith("http://schemas.openxmlformats.org/officeDocument/2006/relationships/") || type.startsWith("http://purl.oclc.org/ooxml/officeDocument/relationships/") || type.startsWith("http://schemas.microsoft.com/office/")) && COMMENT_TYPES.contains(type.substring(type.lastIndexOf('/') + 1)); } private static boolean external(Element link) { return "External".equals(link.getAttribute("TargetMode")); } private static String owner(String rels) throws IOException { if (rels.equals("_rels/.rels")) return ""; int index = rels.lastIndexOf("/_rels/"); if (index < 0 || !rels.endsWith(".rels")) throw new IOException("Invalid relationship part"); return rels.substring(0, index + 1) + rels.substring(index + 7, rels.length() - 5); } private static String rels(String part) { int index = part.lastIndexOf('/'); return part.substring(0, index + 1) + "_rels/" + part.substring(index + 1) + ".rels"; } private static String target(String owner, String value) throws IOException { try { URI resolved = URI.create("/" + owner).resolve(value).normalize(); String path = resolved.getPath(); if (resolved.isAbsolute() || resolved.getAuthority() != null || path == null || !path.startsWith("/") || path.contains("/../")) throw new IOException("Invalid package target"); return path.substring(1); } catch (IllegalArgumentException e) { throw new IOException("Invalid package target", e); } } private static byte[] required(Map parts, String name) throws IOException { byte[] data = parts.get(name); if (data == null) throw new IOException("Missing package part: " + name); return data; } private static Map read(MultipartFile source) throws IOException { Map parts = new LinkedHashMap<>(); int total = 0, count = 0; try (ZipInputStream zip = new ZipInputStream(source.getInputStream())) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { String name = entry.getName(); if (++count > MAX_PARTS || name.startsWith("/") || name.contains("\\") || Arrays.asList(name.split("/")).contains("..") || parts.containsKey(name)) throw new IOException("Unsafe or oversized DOCX package"); if (entry.isDirectory()) continue; ByteArrayOutputStream bytes = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int read; while ((read = zip.read(buffer)) != -1) { total += read; if (total > MAX_BYTES) throw new IOException("DOCX exceeds 50MB expanded limit"); bytes.write(buffer, 0, read); } parts.put(name, bytes.toByteArray()); } } return parts; } private static Document parse(byte[] bytes) throws Exception { if (bytes.length > MAX_XML) throw new IOException("DOCX XML part exceeds 16MB limit"); DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultInstance(); factory.setNamespaceAware(true); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); } private static boolean removeMarkers(Document doc) { NodeList nodes = doc.getElementsByTagNameNS("*", "*"); boolean changed = false; for (int i = nodes.getLength() - 1; i >= 0; i--) { Node node = nodes.item(i); if (MARKERS.contains(node.getLocalName()) && ("http://schemas.openxmlformats.org/wordprocessingml/2006/main".equals(node.getNamespaceURI()) || "http://purl.oclc.org/ooxml/wordprocessingml/main".equals(node.getNamespaceURI()))) { node.getParentNode().removeChild(node); changed = true; } } return changed; } private static byte[] serialize(Document doc) throws Exception { TransformerFactory factory = TransformerFactory.newDefaultInstance(); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); ByteArrayOutputStream output = new ByteArrayOutputStream(); factory.newTransformer().transform(new DOMSource(doc), new StreamResult(output)); return output.toByteArray(); } private record CleanedFile(String name, String originalFilename, String contentType, byte[] bytes) implements MultipartFile { public String getName() { return name; } public String getOriginalFilename() { return originalFilename; } public String getContentType() { return contentType; } public boolean isEmpty() { return bytes.length == 0; } public long getSize() { return bytes.length; } public byte[] getBytes() { return bytes.clone(); } public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); } } }