刘光辉
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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
package jnpf.bizcommon.onlyoffice.service.impl;
 
import cn.hutool.crypto.digest.DigestUtil;
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 jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties;
import jnpf.dmsEntity.preview.model.DmsFilePreviewTicket;
import jnpf.exception.DataException;
import jnpf.util.JsonUtil;
import jnpf.util.TicketUtil;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
 
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
 
@Component
public class OnlyOfficeDmsPrintRenderer {
    private static final Set<String> CONVERTIBLE_TYPES = Set.of(
            "doc", "docm", "docx", "dot", "dotx", "odp", "ods", "odt",
            "otp", "ots", "ott", "pdf", "pot", "potx", "pps", "ppsx",
            "ppt", "pptm", "pptx", "rtf", "txt", "xls", "xlsm", "xlsx",
            "xlt", "xltx");
    private static final long PRINT_BASE_TICKET_TTL_SECONDS = 180L;
 
    private final OnlyOfficeProperties properties;
    private final Function<DmsFilePreviewTicket, String> printBaseTicketIssuer;
 
    @Autowired
    public OnlyOfficeDmsPrintRenderer(OnlyOfficeProperties properties) {
        this(properties, ticket -> TicketUtil.createTicket(
                JsonUtil.getObjectToString(ticket), PRINT_BASE_TICKET_TTL_SECONDS));
    }
 
    OnlyOfficeDmsPrintRenderer(OnlyOfficeProperties properties,
                               Function<DmsFilePreviewTicket, String> printBaseTicketIssuer) {
        this.properties = properties;
        this.printBaseTicketIssuer = printBaseTicketIssuer;
    }
 
    public byte[] render(DmsFilePreviewTicket ticket, String accessTicket) {
        return render(ticket, accessTicket, false);
    }
 
    public byte[] renderBase(DmsFilePreviewTicket ticket, String accessTicket) {
        DmsFilePreviewTicket delegated = new DmsFilePreviewTicket();
        BeanUtils.copyProperties(ticket, delegated);
        delegated.setScope(DmsFilePreviewTicket.PRINT_BASE_SCOPE);
        delegated.setExpiresAt(Math.min(ticket.getExpiresAt(),
                System.currentTimeMillis() + PRINT_BASE_TICKET_TTL_SECONDS * 1_000L));
        return render(delegated, printBaseTicketIssuer.apply(delegated), true);
    }
 
    private byte[] render(DmsFilePreviewTicket ticket, String accessTicket, boolean printBase) {
        OnlyOfficeProperties.DmsPrint limits = properties.getDmsPrint();
        if (ticket.getSizeBytes() != null && ticket.getSizeBytes() > limits.getMaxSourceBytes()) {
            throw new DataException("打印源文件超过系统大小限制");
        }
 
        String fileType = printBase && reusablePersistentPdf(ticket)
                ? "pdf" : extension(ticket.getFileName());
        if (!CONVERTIBLE_TYPES.contains(fileType)) {
            throw new DataException("OnlyOffice 不支持该文件格式转换");
        }
        String sourceUrl = sourceUrl(ticket, accessTicket, printBase);
        byte[] pdf;
        if ("pdf".equals(fileType)) {
            try {
                pdf = download(sourceUrl, limits.getConversionTimeoutMillis(),
                        Math.min(limits.getMaxSourceBytes(), limits.getMaxPdfBytes()),
                        "读取打印 PDF 失败");
                validatePdf(pdf, limits);
                return pdf;
            } catch (DataException exception) {
                fileType = extension(ticket.getFileName());
                if (!CONVERTIBLE_TYPES.contains(fileType) || "pdf".equals(fileType)) throw exception;
            }
        }
        pdf = convert(sourceUrl, ticket.getFileName(), fileType,
                limits.getConversionTimeoutMillis(), limits.getMaxPdfBytes());
        validatePdf(pdf, limits);
        return pdf;
    }
 
    private byte[] convert(String sourceUrl, String fileName, String fileType,
                           int timeout, long maxPdfBytes) {
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("async", false);
        request.put("filetype", fileType);
        request.put("key", DigestUtil.sha256Hex(sourceUrl));
        request.put("outputtype", "pdf");
        request.put("title", fileName);
        request.put("url", sourceUrl);
        request.put("token", JWTUtil.createToken(request,
                requireJwtSecret().getBytes(StandardCharsets.UTF_8)));
 
        try (HttpResponse response = HttpRequest.post(
                        serverBase() + "/ConvertService.ashx?json=true")
                .timeout(timeout).body(JSONUtil.toJsonStr(request)).execute()) {
            if (!response.isOk()) {
                throw new DataException("OnlyOffice 文件转换失败");
            }
            JSONObject result = JSONUtil.parseObj(response.body());
            if (!Boolean.TRUE.equals(result.getBool("endConvert"))
                    || result.getInt("error", 0) != 0
                    || isBlank(result.getStr("fileUrl"))) {
                throw new DataException("OnlyOffice 文件转换失败");
            }
            return download(serverBase() + resultPath(result.getStr("fileUrl")), timeout,
                    maxPdfBytes, "下载 OnlyOffice 转换结果失败");
        } catch (DataException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new DataException("OnlyOffice 文件转换失败或超时");
        }
    }
 
    private byte[] download(String url, int timeout, long maxBytes, String errorMessage) {
        try (HttpResponse response = HttpRequest.get(url).timeout(timeout).execute()) {
            if (!response.isOk()) {
                throw new DataException(errorMessage);
            }
            try (InputStream input = response.bodyStream();
                 ByteArrayOutputStream output = new ByteArrayOutputStream()) {
                byte[] buffer = new byte[16 * 1024];
                long total = 0;
                int read;
                while ((read = input.read(buffer)) != -1) {
                    total += read;
                    if (total > maxBytes) {
                        throw new DataException(errorMessage + ":文件超过系统大小限制");
                    }
                    output.write(buffer, 0, read);
                }
                return output.toByteArray();
            }
        } catch (DataException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new DataException(errorMessage);
        }
    }
 
    private void validatePdf(byte[] pdf, OnlyOfficeProperties.DmsPrint limits) {
        if (pdf == null || pdf.length == 0 || pdf.length > limits.getMaxPdfBytes()) {
            throw new DataException("打印 PDF 为空或超过系统大小限制");
        }
        try (PDDocument document = Loader.loadPDF(pdf)) {
            if (document.getNumberOfPages() < 1
                    || document.getNumberOfPages() > limits.getMaxPages()) {
                throw new DataException("打印 PDF 页数超过系统限制");
            }
        } catch (DataException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new DataException("OnlyOffice 转换结果不是有效 PDF");
        }
    }
 
    private String sourceUrl(DmsFilePreviewTicket ticket, String accessTicket, boolean printBase) {
        String base = properties.getFileFetchBaseUrl();
        if (isBlank(base)) {
            throw new DataException("OnlyOffice 文件回源地址未配置");
        }
        return base.replaceAll("/+$", "") + "/api/file/Dms/OnlyOffice/"
                + ticket.getFileVersionId() + "?ticket="
                + URLEncoder.encode(accessTicket, StandardCharsets.UTF_8)
                + (printBase ? "&printBase=true" : "");
    }
 
    private boolean reusablePersistentPdf(DmsFilePreviewTicket ticket) {
        if (ticket.getPdfSource() == null || ticket.getPdfSource().getObjectKey() == null
                || !ticket.getPdfSource().getObjectKey().startsWith("dms-derived/pdf/")
                || ticket.getPdfSource().getObjectKey().contains("..")) return false;
        return ticket.getWatermarks() == null || ticket.getWatermarks().stream()
                .allMatch(mark -> mark != null && Boolean.TRUE.equals(mark.getCustomBarcode()));
    }
 
    static String resultPath(String url) {
        try {
            URI uri = URI.create(url);
            String rawPath = uri.getRawPath();
            String decodedPath = uri.getPath();
            if (isUnsafeResultPath(rawPath, decodedPath)) {
                throw new IllegalArgumentException();
            }
            return rawPath + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery());
        } catch (Exception exception) {
            throw new DataException("无效的 OnlyOffice 转换结果地址");
        }
    }
 
    private static boolean isUnsafeResultPath(String rawPath, String decodedPath) {
        if (rawPath == null || decodedPath == null
                || !decodedPath.startsWith("/cache/files/")
                || decodedPath.indexOf('\\') >= 0
                || decodedPath.indexOf('%') >= 0
                || !decodedPath.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
            return true;
        }
        for (String segment : decodedPath.split("/")) {
            if (".".equals(segment) || "..".equals(segment)) {
                return true;
            }
        }
        return false;
    }
 
    private String extension(String fileName) {
        int position = fileName == null ? -1 : fileName.lastIndexOf('.');
        if (position < 0 || position == fileName.length() - 1) {
            throw new DataException("打印文件缺少有效扩展名");
        }
        return fileName.substring(position + 1).toLowerCase(Locale.ROOT);
    }
 
    private String serverBase() {
        String base = properties.getDsInternalUrl();
        if (isBlank(base)) {
            base = properties.getDsPublicUrl();
        }
        if (isBlank(base)) {
            throw new DataException("OnlyOffice 服务地址未配置");
        }
        return base.replaceAll("/+$", "");
    }
 
    private String requireJwtSecret() {
        String secret = properties.getJwtSecret();
        if (isBlank(secret) || secret.contains("${")) {
            throw new DataException("OnlyOffice 签名密钥未正确配置");
        }
        return secret;
    }
 
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}