package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import cn.hutool.jwt.JWTUtil; import com.fasterxml.jackson.databind.ObjectMapper; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.exception.DataException; import jnpf.onlyoffice.PageWatermarkRequest; import jnpf.util.TicketUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import org.springframework.web.util.UriUtils; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.Semaphore; @Component public class OnlyOfficePageWatermarkRenderer { private static final String JOB_SCOPE = "onlyoffice:dms-page-watermark"; private final OnlyOfficeProperties properties; private final ObjectMapper mapper; private final Semaphore jobs; private final java.util.function.Function issueJob; private final java.util.function.Function readJob; private final java.util.function.Consumer revokeJob; @Autowired public OnlyOfficePageWatermarkRenderer(OnlyOfficeProperties properties, ObjectMapper mapper, @Value("${onlyoffice.watermark-max-concurrent-jobs:2}") int concurrency) { this(properties, mapper, concurrency, payload -> TicketUtil.createTicket(payload, 180L), TicketUtil::parseTicket, TicketUtil::deleteTicket); } OnlyOfficePageWatermarkRenderer(OnlyOfficeProperties properties, ObjectMapper mapper, int concurrency, java.util.function.Function issueJob, java.util.function.Function readJob, java.util.function.Consumer revokeJob) { this.properties = properties; this.mapper = mapper; this.jobs = new Semaphore(Math.max(1, concurrency)); this.issueJob = issueJob; this.readJob = readJob; this.revokeJob = revokeJob; } public String script() { return "var WatermarkInput = Argument;\n" + scriptBody(); } public String script(String jobTicket) { if (jobTicket == null || jobTicket.isBlank()) return script(); try { if (jobTicket.length() > 4096) throw new IllegalArgumentException(); Object stored = readJob.apply(jobTicket); if (!(stored instanceof String)) throw new IllegalArgumentException(); com.fasterxml.jackson.databind.JsonNode payload = mapper.readTree((String) stored); if (!JOB_SCOPE.equals(payload.path("scope").asText()) || payload.path("expiresAt").asLong() <= System.currentTimeMillis() || !payload.path("argument").isObject()) throw new IllegalArgumentException(); // JSON is decoded as data, never concatenated as executable user-supplied code. String literal = mapper.writeValueAsString(payload.get("argument").toString()) .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"); return "var WatermarkInput = JSON.parse(" + literal + ");\n" + scriptBody(); } catch (Exception e) { throw new DataException("水印生成任务不存在或已过期"); } } private String scriptBody() { try (java.io.InputStream input = new ClassPathResource("onlyoffice/page-watermark.js").getInputStream()) { return new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new DataException("指定页水印脚本不可用"); } } public byte[] render(PageWatermarkRequest input) { validate(input); if (!jobs.tryAcquire()) throw new DataException("水印生成任务繁忙,请稍后重试"); String jobTicket = null; boolean builderRequestStarted = false; boolean builderResponseReceived = false; try { String fetchBase = requireBase(properties.getFileFetchBaseUrl()); String secret = properties.getJwtSecret(); if (secret == null || secret.isBlank() || secret.startsWith("${")) { throw new DataException("OnlyOffice 水印生成鉴权未配置"); } Map argument = new LinkedHashMap<>(); argument.put("sourceUrl", fetchBase + "/api/file/Dms/OnlyOffice/" + input.getSourceFileVersionId() + "?ticket=" + UriUtils.encodeQueryParam(input.getSourceTicket(), StandardCharsets.UTF_8)); argument.put("markerPrefix", input.getMarkerPrefix()); // Serialize the typed contract consistently with the internal HTTP endpoint. argument.put("overlays", mapper.convertValue(input.getOverlays(), java.util.List.class)); Map payload = new LinkedHashMap<>(); payload.put("scope", JOB_SCOPE); payload.put("expiresAt", System.currentTimeMillis() + 180_000L); payload.put("argument", argument); jobTicket = issueJob.apply(mapper.writeValueAsString(payload)); if (jobTicket == null || jobTicket.isBlank()) throw new DataException("无法创建水印生成任务"); Map request = new LinkedHashMap<>(); request.put("async", false); request.put("url", fetchBase + "/api/biz/onlyoffice/watermark/script.js?jobTicket=" + UriUtils.encodeQueryParam(jobTicket, StandardCharsets.UTF_8)); // Document Builder forwards argument via process argv; images quickly exceed that limit. // Deliver the bounded JSON through the ticket-protected script body instead. request.put("argument", java.util.Collections.emptyMap()); request.put("token", JWTUtil.createToken(request, secret.getBytes(StandardCharsets.UTF_8))); builderRequestStarted = true; try (HttpResponse response = HttpRequest.post(serverBase() + "/docbuilder") .timeout(90_000).body(JSONUtil.toJsonStr(request)).execute()) { builderResponseReceived = true; if (!response.isOk()) throw new DataException("OnlyOffice 指定页水印生成失败"); JSONObject result = JSONUtil.parseObj(response.body()); JSONObject urls = result.getJSONObject("urls"); if (!Boolean.TRUE.equals(result.getBool("end")) || result.containsKey("error") || urls == null) { throw new DataException("OnlyOffice 未完成指定页水印生成"); } if (urls.containsKey("WatermarkError.txt")) { String diagnostic = new String(readArtifact(urls.getStr("WatermarkError.txt"), "WatermarkError.txt"), StandardCharsets.UTF_8); throw new DataException(diagnosticMessage(diagnostic)); } return readArtifact(urls.getStr("Watermark.docx"), "Watermark.docx"); } } catch (DataException e) { throw e; } catch (Exception e) { // Do not leak the source ticket, JWT or barcode data in error messages. throw new DataException("OnlyOffice 指定页水印生成失败或超时,请稍后重试"); } finally { // A timed-out synchronous call can still be queued inside Document Builder. Keep // its script ticket alive until TTL so that late execution receives valid JS. if (jobTicket != null && (!builderRequestStarted || builderResponseReceived)) { try { revokeJob.accept(jobTicket); } catch (RuntimeException ignored) { /* The job also expires automatically after 180 seconds. */ } } jobs.release(); } } static void validate(PageWatermarkRequest input) { if (input == null || input.getSourceFileVersionId() == null || input.getSourceTicket() == null || input.getSourceTicket().isBlank() || input.getSourceTicket().length() > 4096 || input.getMarkerPrefix() == null || !input.getMarkerPrefix().matches("DMS_CUSTOM_[a-f0-9]{32}") || input.getOverlays() == null || input.getOverlays().isEmpty() || input.getOverlays().size() > 10000) { throw new DataException("指定页水印生成参数无效"); } long totalImageBytes = 0; for (PageWatermarkRequest.Overlay overlay : input.getOverlays()) { if (overlay == null || overlay.getPages() == null || overlay.getPages().isEmpty() || overlay.getPages().size() > 10000 || overlay.getVariants() == null || overlay.getVariants().isEmpty() || overlay.getVariants().size() > 64 || overlay.getPages().stream().anyMatch(page -> page == null || page < 1) || overlay.getPages().stream().distinct().count() != overlay.getPages().size()) { throw new DataException("指定页水印页码或纸张参数无效"); } for (PageWatermarkRequest.Variant variant : overlay.getVariants()) { if (variant == null || !Double.isFinite(variant.getWidthMm()) || !Double.isFinite(variant.getHeightMm()) || variant.getWidthMm() <= 0 || variant.getHeightMm() <= 0 || variant.getWidthMm() > 1000 || variant.getHeightMm() > 1000 || !Double.isFinite(variant.getDrawingWidthMm()) || !Double.isFinite(variant.getDrawingHeightMm()) || !Double.isFinite(variant.getOffsetXMm()) || !Double.isFinite(variant.getOffsetYMm()) || variant.getDrawingWidthMm() <= 0 || variant.getDrawingHeightMm() <= 0 || variant.getOffsetXMm() < 0 || variant.getOffsetYMm() < 0 || variant.getOffsetXMm() + variant.getDrawingWidthMm() > variant.getWidthMm() + 0.2 || variant.getOffsetYMm() + variant.getDrawingHeightMm() > variant.getHeightMm() + 0.2 || variant.getPngBase64() == null || variant.getPngBase64().length() > 16_000_000) { throw new DataException("指定页水印图片参数无效"); } try { byte[] image = Base64.getDecoder().decode(variant.getPngBase64()); totalImageBytes += image.length; if (image.length < 8 || image[0] != (byte) 137 || image[1] != 80 || image[2] != 78 || image[3] != 71 || totalImageBytes > 32_000_000) throw new IllegalArgumentException(); } catch (IllegalArgumentException e) { throw new DataException("指定页水印图片无效或超过处理上限"); } } } } static String diagnosticMessage(String diagnostic) { java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("PAGE_OUT_OF_RANGE\\|(\\d+)\\|(\\d+)").matcher(diagnostic); if (matcher.find()) return "水印页码 " + matcher.group(1) + " 超过文档总页数 " + matcher.group(2); if (diagnostic.contains("UNSUPPORTED_VERSION")) return "当前 OnlyOffice 版本不支持指定页水印"; if (diagnostic.contains("PAGINATION_CHANGED")) return "添加水印后文档分页发生变化,已停止生成"; if (diagnostic.contains("PAGE_ANCHOR_NOT_FOUND")) return "无法定位目标页正文,已停止生成水印"; if (diagnostic.contains("PAGE_SIZE_NOT_FOUND") || diagnostic.contains("PAGE_SECTION_NOT_FOUND")) { return "无法匹配目标页纸张设置,已停止生成水印"; } return "OnlyOffice 无法完成指定各页的水印插入"; } static String resultPath(String url, String fileName) { try { URI uri = URI.create(url); String path = uri.getRawPath(); if (path == null || !path.startsWith("/cache/files/") || path.contains("..") || path.contains("%") || !path.endsWith("/" + fileName)) throw new IllegalArgumentException(); return path + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery()); } catch (Exception e) { throw new DataException("OnlyOffice 水印生成结果地址无效"); } } private byte[] readArtifact(String url, String fileName) { try (HttpResponse response = HttpRequest.get(serverBase() + resultPath(url, fileName)).timeout(30_000).execute()) { if (!response.isOk()) throw new DataException("水印生成结果不存在或已过期"); byte[] body = response.bodyBytes(); if (body.length == 0 || body.length > 100L * 1024 * 1024) throw new DataException("水印生成结果大小无效"); return body; } } private String serverBase() { String base = properties.getDsInternalUrl(); return requireBase(base == null || base.isBlank() ? properties.getDsPublicUrl() : base); } private String requireBase(String base) { if (base == null || base.isBlank()) throw new DataException("OnlyOffice 服务地址未配置"); return base.replaceAll("/+$", ""); } }