diff --git a/assets/webui/index.html b/assets/webui/index.html index 28d8786..0e01433 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -876,7 +876,7 @@ diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 9ff0050..2be4858 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -638,6 +638,7 @@ const el = { structureStatus: document.querySelector("#structureStatus"), structureSubmit: document.querySelector("#structureSubmit"), signatureSummary: document.querySelector("#signatureSummary"), + signatureClear: document.querySelector("#signatureClear"), signatureList: document.querySelector("#signatureList"), annotationNote: document.querySelector("#annotationNote"), annotationNoteTitle: document.querySelector("#annotationNoteTitle"), @@ -1476,6 +1477,7 @@ el.objectStyleForm.addEventListener("submit", async event => { }); el.outlineCancel.addEventListener("click", () => el.outlinePanel.close()); el.structureManage.addEventListener("click", openStructureDialog); +el.signatureClear.addEventListener("click", () => changeDocument("ofdgoChangeSignature", null, "clear", "")); document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close()); el.structureEntry.addEventListener("change", refreshStructureNodes); el.structureNodeSelect.addEventListener("change", refreshStructureFields); @@ -9190,6 +9192,10 @@ function renderSecurity() { el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || document.body.hasAttribute("data-busy"); el.verifyButton.disabled = el.encryptSaveButton.disabled; el.signButton.disabled = el.encryptSaveButton.disabled; + el.signatureClear.hidden = !state.editing; + el.signatureClear.disabled = el.encryptSaveButton.disabled || !state.editing || !!state.doc?.detailsPending || !state.doc?.signatures?.length; + if (!state.doc?.detailsPending) renderMetaContent(el.signatureList, [documentTab().id, documentContentKey(state), state.doc?.signatures, state.doc?.signatureError, state.editing], renderSignatures); + for (const button of el.signatureList.querySelectorAll(".signature-remove")) button.disabled = el.signatureClear.disabled; } function cancelSigning() { @@ -11540,7 +11546,6 @@ function renderMeta(keepDetails = false) { el.pageTotal.textContent = String(doc.pageCount || 0); if (keepDetails && doc.detailsPending) return; el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0); - renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures); updateStructureControls(); renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts); refreshEditorFonts(); @@ -11811,6 +11816,18 @@ function renderSignatures() { badges.className = "signature-badges"; badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status)); + if (state.editing) { + const tab = documentTab(), key = documentContentKey(state); + const remove = document.createElement("button"); + remove.className = "small-button signature-remove"; + remove.type = "button"; + remove.textContent = "移除"; + remove.disabled = el.signatureClear.disabled; + remove.addEventListener("click", () => { + if (isForegroundDocument(tab) && documentContentKey(state) === key) changeDocument("ofdgoChangeSignature", null, "remove", signature.id); + }); + badges.append(remove); + } head.append(badges, name); row.append(head); appendInfoLine(row, "签署人", signature.signer); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index d744479..5d20e70 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -6746,6 +6746,25 @@ func (e *businessEngine) changeAttachment(args []platformValue) (any, error) { }) } +// changeSignature 移除当前文档签名,使用库层依赖检查和撤销历史 +// 入参: args 操作及签名标识 +// 返回: any 编辑状态, error 移除错误 +func (e *businessEngine) changeSignature(args []platformValue) (any, error) { + if len(args) != 2 { + return nil, fmt.Errorf("签名参数无效") + } + return e.changeAtomicObjects(func() error { + switch args[0].String() { + case "remove": + return e.currentEditor.RemoveSignatureContext(e.operationContext(), args[1].String()) + case "clear": + return e.currentEditor.ClearSignaturesContext(e.operationContext()) + default: + return fmt.Errorf("签名操作无效") + } + }) +} + // changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态 // 入参: args 字体标识、保存方式及可选字体数据 // 返回: any 编辑状态, error 字体处理错误 @@ -7980,6 +7999,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoEditDocument": e.editDocument, "ofdgoUpdateInfo": e.updateInfo, "ofdgoChangeAttachment": e.changeAttachment, + "ofdgoChangeSignature": e.changeSignature, "ofdgoChangePage": e.changePage, "ofdgoBatchPages": e.batchPages, "ofdgoChangeOutline": e.changeOutline, @@ -8068,6 +8088,8 @@ func (e *businessEngine) operationContext() context.Context { func failureResult(err error) apiResult { result := apiResult{Error: err.Error()} switch { + case errors.Is(err, ofdgo.ErrSignatureDependency): + result.Code, result.Error = "signatureDependency", "存在签名或版本依赖,无法移除" case errors.Is(err, ofdgo.ErrIncompleteDocument): result.Code, result.Error = "documentPending", "文档尚未准备完成" case errors.Is(err, ofdgo.ErrPrintDenied): diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go index 8cc7009..ea8f4ee 100644 --- a/ofdgo_sign_write.go +++ b/ofdgo_sign_write.go @@ -21,8 +21,10 @@ import ( "crypto" "encoding/base64" "encoding/xml" + "errors" "fmt" "io" + "maps" "path" "slices" "strconv" @@ -40,6 +42,256 @@ const ( // SignatureWriteMode 签署方式 type SignatureWriteMode uint8 +// ErrSignatureDependency 表示移除操作会改变保留签名或其他版本使用的入口和保护文件 +var ErrSignatureDependency = errors.New("signature dependency conflict") + +// RemoveSignature 移除当前文档版本的指定签名及外观,支持撤销重做 +// 仅清理无其他引用的签名文件,依赖冲突时不修改文档 +// 入参: id 签名标识 +// 返回: error 标识、依赖或读取错误 +func (e *Editor) RemoveSignature(id string) error { + return e.RemoveSignatureContext(context.Background(), id) +} + +// RemoveSignatureContext 移除指定签名,取消或失败时不改变文档和历史 +// 入参: ctx 取消上下文, id 签名标识 +// 返回: error 标识、依赖、读取或取消错误 +func (e *Editor) RemoveSignatureContext(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("empty signature ID") + } + return e.removeSignatures(ctx, id) +} + +// ClearSignatures 清空当前文档版本的签名及外观,保留其他文档和版本的引用 +// 无签名时不产生历史记录,依赖冲突时不修改文档 +// 返回: error 依赖或读取错误 +func (e *Editor) ClearSignatures() error { + return e.ClearSignaturesContext(context.Background()) +} + +// ClearSignaturesContext 清空当前签名,取消或失败时不改变文档和历史 +// 入参: ctx 取消上下文 +// 返回: error 依赖、读取或取消错误 +func (e *Editor) ClearSignaturesContext(ctx context.Context) error { + return e.removeSignatures(ctx, "") +} + +// removeSignatures 在独立快照中移除签名入口,校验保留依赖后提交历史 +// 入参: ctx 取消上下文, id 签名标识,空值清空 +// 返回: error 标识、依赖、结构或取消错误 +func (e *Editor) removeSignatures(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + reader, err := e.readerContext(ctx, nil) + if err != nil { + return err + } + doc, err := reader.DocContext(ctx) + if err != nil { + return err + } + if doc.Signatures == "" { + if id != "" { + return fmt.Errorf("signature not found: %s", id) + } + return nil + } + name := reader.ResPath(doc.Signatures) + data, err := reader.readFileViewContext(ctx, name) + if err != nil { + return err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return err + } + removed := make(map[string]bool) + candidates := make(map[string]bool) + var patches []editorXMLPatch + remaining := 0 + for _, node := range root.children { + if !node.matchesOFD("Signature") { + continue + } + if id != "" && node.attr("ID") != id { + remaining++ + continue + } + location := signatureRefPath(name, node.attr("BaseLoc")) + files, _, err := reader.signatureRemovalFiles(ctx, location) + if err != nil { + return err + } + maps.Copy(candidates, files) + removed[node.attr("ID")] = true + patches = append(patches, editorXMLPatch{start: node.start, end: node.end}) + } + if id != "" && !removed[id] { + return fmt.Errorf("signature not found: %s", id) + } + protected, references, err := reader.signatureRemovalProtected(ctx, name, removed) + if err != nil { + return err + } + parts := make(map[string][]byte) + if reader.versionInfo != nil { + if remaining != 0 { + return fmt.Errorf("%w: version shares signature list %s", ErrSignatureDependency, name) + } + candidates[name] = true + used, err := reader.versionFileDependencies(ctx, reader.documentRoot, nil, nil) + if err != nil { + return err + } + removedFiles := maps.Clone(candidates) + for file := range used { + delete(removedFiles, file) + } + shared, err := reader.versionSharedFiles(ctx) + if err != nil { + return err + } + if err := reader.versionManifest(ctx, parts, nil, used, shared, removedFiles); err != nil { + return err + } + } else { + for file := range candidates { + if references[file] { + return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, file) + } + } + versions, err := reader.DocumentVersionsContext(ctx) + if err != nil { + return err + } + for _, version := range versions { + for _, file := range version.Files { + if file.Location == name { + return fmt.Errorf("%w: version %s uses %s", ErrSignatureDependency, version.ID, name) + } + } + } + entry, err := reader.readFileViewContext(ctx, "OFD.xml") + if err != nil { + return err + } + ofd, err := parseEditorXMLContext(ctx, entry) + if err != nil { + return err + } + node := ofd.childAt("DocBody", reader.documentIndex).child("Signatures") + if remaining == 0 { + parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{{start: node.start, end: node.end}}) + candidates[name] = true + } else { + if references[name] { + return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name) + } + target := name + shared := false + for index, body := range reader.OFD.DocBody { + shared = shared || index != reader.documentIndex && body.Signatures != "" && signatureRefPath("OFD.xml", body.Signatures) == name + } + if shared { + target = packageAvailableName(reader, parts, name) + parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{editorXMLContent(entry, node, editorFontXMLText("/"+target))}) + } + parts[target] = editorPatchXML(data, patches) + } + } + for file, updated := range parts { + if !protected[file] { + continue + } + same, err := packageDataEqual(ctx, reader, file, updated) + if err != nil { + return err + } + if !same { + return fmt.Errorf("%w: protected file %s", ErrSignatureDependency, file) + } + } + for file, data := range reader.files { + if _, changed := parts[file]; !changed { + parts[file] = data + } + } + reader.files = parts + for file := range protected { + delete(candidates, file) + } + if err := reader.retireVersionFiles(ctx, candidates); err != nil { + return err + } + return e.commitResourceSnapshot(ctx, reader) +} + +// signatureRemovalFiles 区分签名自有文件和摘要保护内容,正文不作为删除候选 +// 入参: ctx 取消上下文, name 签名描述路径 +// 返回: map[string]bool 自有文件, map[string]bool 保护文件, error 读取或解析错误 +func (r *Reader) signatureRemovalFiles(ctx context.Context, name string) (map[string]bool, map[string]bool, error) { + data, err := r.readFileViewContext(ctx, name) + if err != nil { + return nil, nil, err + } + var signature SignatureFile + if err := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}).Decode(&signature); err != nil { + return nil, nil, err + } + owned := map[string]bool{name: true} + for _, location := range []string{signature.SignedValue, signature.SignedInfo.Seal.BaseLoc} { + if location != "" { + owned[signatureRefPath(name, location)] = true + } + } + protected := make(map[string]bool) + for _, reference := range signature.SignedInfo.References.Reference { + protected[signatureRefPath(name, reference.FileRef)] = true + } + return owned, protected, ctx.Err() +} + +// signatureRemovalProtected 汇总保留签名的原文及其他文档的签名入口,不要求证书或私钥 +// 入参: ctx 取消上下文, selected 当前签名列表, removed 待移除标识 +// 返回: map[string]bool 保留路径, map[string]bool 摘要保护路径, error 读取、结构或取消错误 +func (r *Reader) signatureRemovalProtected(ctx context.Context, selected string, removed map[string]bool) (map[string]bool, map[string]bool, error) { + protected := make(map[string]bool) + references := make(map[string]bool) + for index, body := range r.OFD.DocBody { + if body.Signatures == "" { + continue + } + name := signatureRefPath("OFD.xml", body.Signatures) + current := index == r.documentIndex && r.versionInfo == nil + if !current { + protected[name] = true + } + data, err := r.readFileViewContext(ctx, name) + if err != nil { + return nil, nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, nil, err + } + for _, node := range root.children { + if !node.matchesOFD("Signature") || current && name == selected && removed[node.attr("ID")] { + continue + } + owned, refs, err := r.signatureRemovalFiles(ctx, signatureRefPath(name, node.attr("BaseLoc"))) + if err != nil { + return nil, nil, err + } + maps.Copy(protected, owned) + maps.Copy(protected, refs) + maps.Copy(references, refs) + } + } + return protected, references, ctx.Err() +} + // SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件 // SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert // Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容