刘光辉
8 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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("/+$", "");
    }
}