From 33747c46ebf95f5587ab6a99165c165df5df36c4 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 20 Feb 2026 11:50:23 +0100 Subject: [PATCH] Add file stamp Signed-off-by: Bryan Frimin --- pkg/esign/service.go | 115 ++++++++++++++++++++++--- pkg/esign/stamp.go | 42 +++++++++ pkg/server/api/trust/v1/v1_resolver.go | 15 ++++ 3 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 pkg/esign/stamp.go diff --git a/pkg/esign/service.go b/pkg/esign/service.go index 577e07e7d..d29054359 100644 --- a/pkg/esign/service.go +++ b/pkg/esign/service.go @@ -15,11 +15,13 @@ package esign import ( + "bytes" "context" "fmt" "strings" "time" + "go.gearno.de/crypto/uuid" "go.gearno.de/kit/httpclient" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" @@ -123,12 +125,6 @@ func (s *Service) Run(ctx context.Context, presenterConfigFunc EmailPresenterCon return g.Wait() } -// CreateSignatureRequest contains the parameters for creating a PENDING -// electronic signature. - -// CreateSignature creates a PENDING electronic signature row. The conn -// parameter allows the caller to include this insert inside its own -// transaction. func (s *Service) CreateSignature( ctx context.Context, conn pg.Conn, @@ -142,8 +138,6 @@ func (s *Service) CreateSignature( return nil, fmt.Errorf("cannot derive consent text: %w", err) } } else { - // Caller provided explicit text; append e-sign process consent - // suffix if not already present. if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) { consentText = consentText + " " + coredata.ESignProcessConsentText } @@ -152,12 +146,19 @@ func (s *Service) CreateSignature( now := time.Now() scope := coredata.NewScopeFromObjectID(req.OrganizationID) + signatureID := gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEntityType) + + stampedFileID, err := s.createStampedDocument(ctx, conn, scope, req.OrganizationID, req.FileID, signatureID) + if err != nil { + return nil, fmt.Errorf("cannot create stamped document: %w", err) + } + sig := &coredata.ElectronicSignature{ - ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEntityType), + ID: signatureID, OrganizationID: req.OrganizationID, Status: coredata.ElectronicSignatureStatusPending, DocumentType: req.DocumentType, - FileID: req.FileID, + FileID: stampedFileID, SignerEmail: req.SignerEmail.String(), ConsentText: consentText, SealVersion: 1, @@ -174,6 +175,63 @@ func (s *Service) CreateSignature( return sig, nil } +func (s *Service) createStampedDocument( + ctx context.Context, + conn pg.Conn, + scope coredata.Scoper, + organizationID gid.GID, + originalFileID gid.GID, + signatureID gid.GID, +) (gid.GID, error) { + var originalFile coredata.File + if err := originalFile.LoadByID(ctx, conn, scope, originalFileID); err != nil { + return gid.GID{}, fmt.Errorf("cannot load original file: %w", err) + } + + pdfData, err := s.fileManager.GetFileBytes(ctx, &originalFile) + if err != nil { + return gid.GID{}, fmt.Errorf("cannot download original file: %w", err) + } + + stampedData, err := StampSignatureID(pdfData, signatureID.String()) + if err != nil { + return gid.GID{}, fmt.Errorf("cannot stamp signature ID: %w", err) + } + + now := time.Now() + stampedFile := coredata.File{ + ID: gid.New(scope.GetTenantID(), coredata.FileEntityType), + OrganizationID: organizationID, + BucketName: s.bucket, + MimeType: "application/pdf", + FileName: originalFile.FileName, + FileKey: uuid.MustNewV4().String(), + CreatedAt: now, + UpdatedAt: now, + } + + stampedSize, err := s.fileManager.PutFile( + ctx, + &stampedFile, + bytes.NewReader(stampedData), + map[string]string{ + "type": "stamped-document", + "signature-id": signatureID.String(), + }, + ) + if err != nil { + return gid.GID{}, fmt.Errorf("cannot upload stamped file: %w", err) + } + + stampedFile.FileSize = stampedSize + + if err := stampedFile.Insert(ctx, conn, scope); err != nil { + return gid.GID{}, fmt.Errorf("cannot insert stamped file record: %w", err) + } + + return stampedFile.ID, nil +} + func (s *Service) AcceptSignature(ctx context.Context, req *AcceptSignatureRequest) (*coredata.ElectronicSignature, error) { var ( scope = coredata.NewScopeFromObjectID(req.SignatureID) @@ -323,6 +381,43 @@ func (s *Service) GenerateCertificateFileURL( return url, nil } +func (s *Service) GenerateSignatureFileURL( + ctx context.Context, + signatureID gid.GID, + expiresIn time.Duration, +) (string, error) { + var ( + scope = coredata.NewScopeFromObjectID(signatureID) + signature coredata.ElectronicSignature + file coredata.File + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := signature.LoadByID(ctx, conn, scope, signatureID); err != nil { + return fmt.Errorf("cannot load electronic signature: %w", err) + } + + if err := file.LoadByID(ctx, conn, scope, signature.FileID); err != nil { + return fmt.Errorf("cannot load signature file: %w", err) + } + + return nil + }, + ) + if err != nil { + return "", err + } + + url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn) + if err != nil { + return "", fmt.Errorf("cannot generate signature file URL: %w", err) + } + + return url, nil +} + func (s *Service) GetEventsBySignatureID( ctx context.Context, signatureID gid.GID, diff --git a/pkg/esign/stamp.go b/pkg/esign/stamp.go new file mode 100644 index 000000000..8f8fd09ba --- /dev/null +++ b/pkg/esign/stamp.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package esign + +import ( + "bytes" + "fmt" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" +) + +func StampSignatureID(pdfData []byte, signatureID string) ([]byte, error) { + text := fmt.Sprintf("Electronic Signature ID: %s", signatureID) + desc := "fontname:Helvetica, points:9, pos:tl, rot:0, op:1.0, scale:1.0 abs, color:0.3 0.3 0.3, offset:10 -10" + + wm, err := api.TextWatermark(text, desc, true, false, types.POINTS) + if err != nil { + return nil, fmt.Errorf("cannot create signature ID stamp: %w", err) + } + + reader := bytes.NewReader(pdfData) + var buf bytes.Buffer + + if err := api.AddWatermarks(reader, &buf, nil, wm, nil); err != nil { + return nil, fmt.Errorf("cannot stamp signature ID on PDF: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index ca855a70c..4eb56dba5 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -648,6 +648,21 @@ func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.R // FileURL is the resolver for the fileUrl field. func (r *nonDisclosureAgreementResolver) FileURL(ctx context.Context, obj *types.NonDisclosureAgreement) (string, error) { trustCenter := compliancepage.CompliancePageFromContext(ctx) + + if identity := authn.IdentityFromContext(ctx); identity != nil && r.esign != nil { + trustService := r.TrustService(ctx, trustCenter.ID.TenantID()) + + access, err := trustService.TrustCenterAccesses.GetAccess(ctx, trustCenter.ID, identity.EmailAddress) + if err == nil && access.ElectronicSignatureID != nil { + fileURL, err := r.esign.GenerateSignatureFileURL(ctx, *access.ElectronicSignatureID, 15*time.Minute) + if err == nil { + return fileURL, nil + } + + r.logger.ErrorCtx(ctx, "cannot generate signature file URL, falling back to original NDA", log.Error(err)) + } + } + trustService := r.TrustService(ctx, trustCenter.ID.TenantID()) fileURL, err := trustService.TrustCenters.GenerateNDAFileURL(ctx, trustCenter.ID, 15*time.Minute)