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<String, String> issueJob;
|
private final java.util.function.Function<String, Object> readJob;
|
private final java.util.function.Consumer<String> 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<String, String> issueJob, java.util.function.Function<String, Object> readJob,
|
java.util.function.Consumer<String> 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<String, Object> 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<String, Object> 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<String, Object> 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("/+$", "");
|
}
|
}
|