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 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 printBaseTicketIssuer; @Autowired public OnlyOfficeDmsPrintRenderer(OnlyOfficeProperties properties) { this(properties, ticket -> TicketUtil.createTicket( JsonUtil.getObjectToString(ticket), PRINT_BASE_TICKET_TTL_SECONDS)); } OnlyOfficeDmsPrintRenderer(OnlyOfficeProperties properties, Function 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 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(); } }