diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 66d52594d..111bb1fda 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -3,11 +3,14 @@ package probo import ( "context" "fmt" + "io" "net/url" "time" "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/docgen" "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/statelesstoken" "github.com/jackc/pgx/v5" @@ -16,7 +19,8 @@ import ( type ( DocumentService struct { - svc *TenantService + svc *TenantService + html2pdfConverter *html2pdf.Converter } CreateDocumentRequest struct { @@ -859,3 +863,134 @@ func (s *DocumentService) Update( return document, nil } + +func (s *DocumentService) ExportPDF( + ctx context.Context, + documentVersionID gid.GID, +) ([]byte, error) { + document := &coredata.Document{} + version := &coredata.DocumentVersion{} + owner := &coredata.People{} + publishedBy := &coredata.People{} + signatures := coredata.DocumentVersionSignatures{} + peopleMap := make(map[gid.GID]*coredata.People) + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := version.LoadByID(ctx, conn, s.svc.scope, documentVersionID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } + + if err := document.LoadByID(ctx, conn, s.svc.scope, version.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if version.PublishedBy != nil { + if err := publishedBy.LoadByID(ctx, conn, s.svc.scope, *version.PublishedBy); err != nil { + return fmt.Errorf("cannot load published by person: %w", err) + } + } + + cursor := page.NewCursor( + 100, + nil, + page.Head, + page.OrderBy[coredata.DocumentVersionSignatureOrderField]{ + Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + ) + + if err := signatures.LoadByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil { + return fmt.Errorf("cannot load document version signatures: %w", err) + } + + if err := owner.LoadByID(ctx, conn, s.svc.scope, document.OwnerID); err != nil { + return fmt.Errorf("cannot load document owner: %w", err) + } + + // TODO: refactor this to use a single query + for _, sig := range signatures { + if _, ok := peopleMap[sig.SignedBy]; !ok { + people := &coredata.People{} + if err := people.LoadByID(ctx, conn, s.svc.scope, sig.SignedBy); err != nil { + return fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err) + } + peopleMap[sig.SignedBy] = people + } + + if _, ok := peopleMap[sig.RequestedBy]; !ok { + people := &coredata.People{} + if err := people.LoadByID(ctx, conn, s.svc.scope, sig.RequestedBy); err != nil { + return fmt.Errorf("cannot load people %q: %w", sig.RequestedBy, err) + } + peopleMap[sig.RequestedBy] = people + } + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + classification := docgen.ClassificationInternal + switch document.DocumentType { + case coredata.DocumentTypePolicy: + classification = docgen.ClassificationConfidential + case coredata.DocumentTypeISMS: + classification = docgen.ClassificationSecret + } + + docData := docgen.DocumentData{ + Title: version.Title, + Content: version.Content, + Version: version.VersionNumber, + Classification: classification, + Approver: owner.FullName, + Description: version.Changelog, + PublishedAt: version.PublishedAt, + PublishedBy: publishedBy.FullName, + Signatures: make([]docgen.SignatureData, len(signatures)), + } + + for i, sig := range signatures { + docData.Signatures[i] = docgen.SignatureData{ + SignedBy: peopleMap[sig.SignedBy].FullName, + SignedAt: sig.SignedAt, + State: sig.State, + RequestedAt: sig.RequestedAt, + RequestedBy: peopleMap[sig.RequestedBy].FullName, + } + } + + htmlContent, err := docgen.RenderHTML(docData) + if err != nil { + return nil, fmt.Errorf("cannot generate HTML: %w", err) + } + + cfg := html2pdf.RenderConfig{ + PageFormat: html2pdf.PageFormatA4, + Orientation: html2pdf.OrientationPortrait, + MarginTop: html2pdf.NewMarginInches(1.0), + MarginBottom: html2pdf.NewMarginInches(1.0), + MarginLeft: html2pdf.NewMarginInches(1.0), + MarginRight: html2pdf.NewMarginInches(1.0), + PrintBackground: true, + Scale: 1.0, + } + + pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) + if err != nil { + return nil, fmt.Errorf("cannot generate PDF: %w", err) + } + + pdfData, err := io.ReadAll(pdfReader) + if err != nil { + return nil, fmt.Errorf("cannot read PDF data: %w", err) + } + return pdfData, nil +} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 0d93602ff..3a3fecdde 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -24,18 +24,20 @@ import ( "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/filevalidation" "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/html2pdf" "go.gearno.de/kit/pg" ) type ( Service struct { - pg *pg.Client - s3 *s3.Client - bucket string - encryptionKey cipher.EncryptionKey - hostname string - tokenSecret string - agentConfig agents.Config + pg *pg.Client + s3 *s3.Client + bucket string + encryptionKey cipher.EncryptionKey + hostname string + tokenSecret string + agentConfig agents.Config + html2pdfConverter *html2pdf.Converter } TenantService struct { @@ -73,19 +75,21 @@ func NewService( hostname string, tokenSecret string, agentConfig agents.Config, + html2pdfConverter *html2pdf.Converter, ) (*Service, error) { if bucket == "" { return nil, fmt.Errorf("bucket is required") } svc := &Service{ - pg: pgClient, - s3: s3Client, - bucket: bucket, - encryptionKey: encryptionKey, - hostname: hostname, - tokenSecret: tokenSecret, - agentConfig: agentConfig, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + hostname: hostname, + tokenSecret: tokenSecret, + agentConfig: agentConfig, + html2pdfConverter: html2pdfConverter, } return svc, nil @@ -120,7 +124,10 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { } tenantService.Peoples = &PeopleService{svc: tenantService} tenantService.Vendors = &VendorService{svc: tenantService} - tenantService.Documents = &DocumentService{svc: tenantService} + tenantService.Documents = &DocumentService{ + svc: tenantService, + html2pdfConverter: s.html2pdfConverter, + } tenantService.Organizations = &OrganizationService{ svc: tenantService, fileValidator: filevalidation.NewValidator( diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 82a628094..bf9578242 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -30,6 +30,7 @@ import ( "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/crypto/passwdhash" + "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/mailer" "github.com/getprobo/probo/pkg/probo" "github.com/getprobo/probo/pkg/saferedirect" @@ -61,6 +62,7 @@ type ( Mailer mailerConfig `json:"mailer"` Connectors []connectorConfig `json:"connectors"` OpenAI openaiConfig `json:"openai"` + ChromeDPAddr string `json:"chrome-dp-addr"` } ) @@ -83,6 +85,7 @@ func New() *Implm { Database: "probod", PoolSize: 100, }, + ChromeDPAddr: "localhost:9222", Auth: authConfig{ Password: passwordConfig{ Pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes", @@ -168,6 +171,12 @@ func (impl *Implm) Run( }, ) + html2pdfConverter := html2pdf.NewConverter( + impl.cfg.ChromeDPAddr, + html2pdf.WithLogger(l), + html2pdf.WithTracerProvider(tp), + ) + s3Client := s3.NewFromConfig(awsConfig) err = migrator.NewMigrator(pgClient, coredata.Migrations, l.Named("migrations")).Run(ctx, "migrations") @@ -216,6 +225,7 @@ func (impl *Implm) Run( impl.cfg.Hostname, impl.cfg.Auth.Cookie.Secret, agentConfig, + html2pdfConverter, ) if err != nil { return fmt.Errorf("cannot create probo service: %w", err)