Extract complianceportal package from trust and probo

Split the compliance portal into an admin-facing management side and a
public-facing visitor side under pkg/complianceportal. Trust-center CRUD,
domains, custom links, frameworks, files and accesses move out of
pkg/probo, and the visitor read logic moves out of pkg/trust. IAM actions
migrate onto compliance-portal scopes.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-10 15:12:51 +02:00
parent 55a8e72c17
commit 44ad34561e
41 changed files with 2102 additions and 1544 deletions

View File

@@ -33,76 +33,6 @@ const (
ActionOrganizationContextGet = "core:organization-context:get"
ActionOrganizationContextUpdate = "core:organization-context:update"
// TrustCenter actions
ActionTrustCenterGet = "core:trust-center:get"
ActionTrustCenterUpdate = "core:trust-center:update"
ActionTrustCenterGetNda = "core:trust-center:get-nda"
ActionTrustCenterNonDisclosureAgreementUpload = "core:trust-center:upload-nda"
ActionTrustCenterNonDisclosureAgreementDelete = "core:trust-center:delete-nda"
// TrustCenterAccess actions
ActionTrustCenterAccessGet = "core:trust-center-access:get"
ActionTrustCenterAccessList = "core:trust-center-access:list"
ActionTrustCenterAccessCreate = "core:trust-center-access:create"
ActionTrustCenterAccessUpdate = "core:trust-center-access:update"
ActionTrustCenterAccessDelete = "core:trust-center-access:delete"
// MailingListUpdate actions
ActionMailingListUpdateList = "core:mailing-list-update:list"
ActionMailingListUpdateCreate = "core:mailing-list-update:create"
ActionMailingListUpdateUpdate = "core:mailing-list-update:update"
ActionMailingListUpdateSend = "core:mailing-list-update:send"
ActionMailingListUpdateDelete = "core:mailing-list-update:delete"
// MailingList actions
ActionMailingListUpdate = "core:mailing-list:update"
// MailingListSubscriber actions
ActionMailingListSubscriberList = "core:mailing-list-subscriber:list"
ActionMailingListSubscriberCreate = "core:mailing-list-subscriber:create"
ActionMailingListSubscriberDelete = "core:mailing-list-subscriber:delete"
// TrustCenterReference actions
ActionTrustCenterReferenceList = "core:trust-center-reference:list"
ActionTrustCenterReferenceGetLogoUrl = "core:trust-center-reference:get-logo-url"
ActionTrustCenterReferenceCreate = "core:trust-center-reference:create"
ActionTrustCenterReferenceUpdate = "core:trust-center-reference:update"
ActionTrustCenterReferenceDelete = "core:trust-center-reference:delete"
// CompliancePortalCommitmentGroup actions
ActionCompliancePortalCommitmentGroupList = "core:compliance-portal-commitment-group:list"
ActionCompliancePortalCommitmentGroupCreate = "core:compliance-portal-commitment-group:create"
ActionCompliancePortalCommitmentGroupUpdate = "core:compliance-portal-commitment-group:update"
ActionCompliancePortalCommitmentGroupUpdateRank = "core:compliance-portal-commitment-group:update-rank"
ActionCompliancePortalCommitmentGroupDelete = "core:compliance-portal-commitment-group:delete"
// CompliancePortalCommitment actions
ActionCompliancePortalCommitmentList = "core:compliance-portal-commitment:list"
ActionCompliancePortalCommitmentCreate = "core:compliance-portal-commitment:create"
ActionCompliancePortalCommitmentUpdate = "core:compliance-portal-commitment:update"
ActionCompliancePortalCommitmentUpdateRank = "core:compliance-portal-commitment:update-rank"
ActionCompliancePortalCommitmentDelete = "core:compliance-portal-commitment:delete"
// ComplianceFramework actions
ActionComplianceFrameworkList = "core:compliance-framework:list"
ActionComplianceFrameworkCreate = "core:compliance-framework:create"
ActionComplianceFrameworkDelete = "core:compliance-framework:delete"
ActionComplianceFrameworkUpdateRank = "core:compliance-framework:update-rank"
// ComplianceExternalURL actions
ActionComplianceExternalURLList = "core:compliance-external-url:list"
ActionComplianceExternalURLCreate = "core:compliance-external-url:create"
ActionComplianceExternalURLUpdate = "core:compliance-external-url:update"
ActionComplianceExternalURLDelete = "core:compliance-external-url:delete"
// TrustCenterFile actions
ActionTrustCenterFileGet = "core:trust-center-file:get"
ActionTrustCenterFileList = "core:trust-center-file:list"
ActionTrustCenterFileGetFileUrl = "core:trust-center-file:get-file-url"
ActionTrustCenterFileUpdate = "core:trust-center-file:update"
ActionTrustCenterFileDelete = "core:trust-center-file:delete"
ActionTrustCenterFileCreate = "core:trust-center-file:create"
// ThirdParty actions
ActionThirdPartyList = "core:thirdParty:list"
ActionThirdPartyGet = "core:thirdParty:get"
@@ -214,16 +144,17 @@ const (
ActionDocumentDeleteDraft = "core:document:delete-draft"
// DocumentVersion actions
ActionDocumentVersionGet = "core:document-version:get"
ActionDocumentVersionList = "core:document-version:list"
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
ActionDocumentVersionSign = "core:document-version:sign"
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
ActionDocumentVersionApprove = "core:document-version:approve"
ActionDocumentVersionReject = "core:document-version:reject"
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export"
ActionDocumentVersionGet = "core:document-version:get"
ActionDocumentVersionList = "core:document-version:list"
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
ActionDocumentVersionSign = "core:document-version:sign"
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
ActionDocumentVersionApprove = "core:document-version:approve"
ActionDocumentVersionReject = "core:document-version:reject"
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export"
// EmployeeDocument actions
ActionEmployeeDocumentGet = "core:employee-document:get"
@@ -306,11 +237,6 @@ const (
ActionProcessingActivityDelete = "core:processing-activity:delete"
ActionProcessingActivityPublish = "core:processing-activity:publish"
// CustomDomain actions
ActionCustomDomainGet = "core:custom-domain:get"
ActionCustomDomainCreate = "core:custom-domain:create"
ActionCustomDomainDelete = "core:custom-domain:delete"
// File actions
ActionFileGet = "core:file:get"
@@ -342,9 +268,6 @@ const (
ActionTransferImpactAssessmentDelete = "core:transfer-impact-assessment:delete"
ActionTransferImpactAssessmentPublish = "core:transfer-impact-assessment:publish"
// TrustCenterDocumentAccess actions
ActionTrustCenterDocumentAccessList = "core:trust-center-document-access:list"
// RightsRequest actions
ActionRightsRequestList = "core:rights-request:list"
ActionRightsRequestGet = "core:rights-request:get"

View File

@@ -1,219 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
ComplianceExternalURLService struct {
svc *Service
}
CreateComplianceExternalURLRequest struct {
TrustCenterID gid.GID
Name string
URL string
}
UpdateComplianceExternalURLRequest struct {
ID gid.GID
Name string
URL string
Rank *int
}
DeleteComplianceExternalURLRequest struct {
ID gid.GID
}
)
func (r *CreateComplianceExternalURLRequest) Validate() error {
v := validator.New()
v.Check(r.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(r.URL, "url", validator.Required(), validator.URL())
return v.Error()
}
func (r *UpdateComplianceExternalURLRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceExternalURLEntityType))
v.Check(r.URL, "url", validator.Required(), validator.URL())
v.Check(r.Rank, "rank", validator.Min(1))
return v.Error()
}
func (r *DeleteComplianceExternalURLRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceExternalURLEntityType))
return v.Error()
}
func (s ComplianceExternalURLService) List(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.ComplianceExternalURLOrderField],
) (*page.Page[*coredata.ComplianceExternalURL, coredata.ComplianceExternalURLOrderField], error) {
var items coredata.ComplianceExternalURLs
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := items.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor); err != nil {
return fmt.Errorf("cannot load compliance external URLs: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(items, cursor), nil
}
func (s ComplianceExternalURLService) Create(
ctx context.Context, scope coredata.Scoper,
req *CreateComplianceExternalURLRequest,
) (*coredata.ComplianceExternalURL, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
id := gid.New(scope.GetTenantID(), coredata.ComplianceExternalURLEntityType)
var item *coredata.ComplianceExternalURL
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
item = &coredata.ComplianceExternalURL{
ID: id,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
Name: req.Name,
URL: req.URL,
CreatedAt: now,
UpdatedAt: now,
}
if err := item.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert compliance external URL: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return item, nil
}
func (s ComplianceExternalURLService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateComplianceExternalURLRequest,
) (*coredata.ComplianceExternalURL, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var item *coredata.ComplianceExternalURL
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
item = &coredata.ComplianceExternalURL{}
if err := item.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load compliance external URL: %w", err)
}
item.Name = req.Name
item.URL = req.URL
item.UpdatedAt = time.Now()
if req.Rank != nil {
item.Rank = *req.Rank
if err := item.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update compliance external URL rank: %w", err)
}
}
if err := item.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update compliance external URL: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return item, nil
}
func (s ComplianceExternalURLService) Delete(
ctx context.Context, scope coredata.Scoper,
req *DeleteComplianceExternalURLRequest,
) error {
if err := req.Validate(); err != nil {
return err
}
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
item := &coredata.ComplianceExternalURL{}
if err := item.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load compliance external URL: %w", err)
}
if err := item.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete compliance external URL: %w", err)
}
return nil
},
)
}

View File

@@ -1,214 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
ComplianceFrameworkService struct {
svc *Service
}
CreateComplianceFrameworkRequest struct {
TrustCenterID gid.GID
FrameworkID gid.GID
}
UpdateComplianceFrameworkRequest struct {
ID gid.GID
Rank int
}
DeleteComplianceFrameworkRequest struct {
ID gid.GID
}
)
func (r *CreateComplianceFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(r.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
return v.Error()
}
func (r *UpdateComplianceFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceFrameworkEntityType))
return v.Error()
}
func (r *DeleteComplianceFrameworkRequest) Validate() error {
v := validator.New()
v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.ComplianceFrameworkEntityType))
return v.Error()
}
func (s ComplianceFrameworkService) ListWithHiddenForTrustCenterID(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.ComplianceFrameworkOrderField],
) (*page.Page[*coredata.ComplianceFramework, coredata.ComplianceFrameworkOrderField], error) {
var cfs coredata.ComplianceFrameworks
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := cfs.LoadWithHiddenByTrustCenterID(ctx, conn, scope, trustCenterID, cursor); err != nil {
return fmt.Errorf("cannot load compliance frameworks with hidden: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(cfs, cursor), nil
}
func (s ComplianceFrameworkService) Create(
ctx context.Context, scope coredata.Scoper,
req *CreateComplianceFrameworkRequest,
) (*coredata.ComplianceFramework, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
cfID := gid.New(scope.GetTenantID(), coredata.ComplianceFrameworkEntityType)
var cf *coredata.ComplianceFramework
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
framework := &coredata.Framework{}
if err := framework.LoadByID(ctx, tx, scope, req.FrameworkID); err != nil {
return fmt.Errorf("cannot load framework: %w", err)
}
cf = &coredata.ComplianceFramework{
ID: cfID,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
FrameworkID: req.FrameworkID,
CreatedAt: now,
UpdatedAt: now,
}
if err := cf.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert compliance framework: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cf, nil
}
func (s ComplianceFrameworkService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateComplianceFrameworkRequest,
) (*coredata.ComplianceFramework, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var cf *coredata.ComplianceFramework
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
cf = &coredata.ComplianceFramework{}
if err := cf.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load compliance framework: %w", err)
}
cf.Rank = req.Rank
cf.UpdatedAt = time.Now()
if err := cf.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update compliance framework rank: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cf, nil
}
func (s ComplianceFrameworkService) Delete(
ctx context.Context, scope coredata.Scoper,
req *DeleteComplianceFrameworkRequest,
) error {
if err := req.Validate(); err != nil {
return err
}
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
cf := &coredata.ComplianceFramework{}
if err := cf.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load compliance framework: %w", err)
}
if err := cf.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete compliance framework: %w", err)
}
return nil
},
)
}

View File

@@ -1,165 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"fmt"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
type (
CustomDomainService struct {
svc *Service
acmeService *certmanager.ACMEService
encryptionKey cipher.EncryptionKey
logger *log.Logger
}
CreateCustomDomainRequest struct {
OrganizationID gid.GID
Domain string
}
)
func (ccdr *CreateCustomDomainRequest) Validate() error {
v := validator.New()
v.Check(ccdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(ccdr.Domain, "domain", validator.Required(), validator.NotEmpty(), validator.Domain())
return v.Error()
}
func (s *CustomDomainService) CreateCustomDomain(
ctx context.Context, scope coredata.Scoper,
req CreateCustomDomainRequest,
) (*coredata.CustomDomain, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var domain *coredata.CustomDomain
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
domain = coredata.NewCustomDomain(scope.GetTenantID(), req.Domain)
domain.OrganizationID = req.OrganizationID
if err := domain.Insert(ctx, tx, scope, s.encryptionKey); err != nil {
return fmt.Errorf("cannot insert custom domain: %w", err)
}
var org coredata.Organization
if err := org.LoadByID(ctx, tx, scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
org.CustomDomainID = &domain.ID
if err := org.Update(ctx, scope, tx); err != nil {
return fmt.Errorf("cannot update organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return domain, nil
}
func (s *CustomDomainService) DeleteCustomDomain(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
) error {
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var org coredata.Organization
if err := org.LoadByID(ctx, tx, scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if org.CustomDomainID == nil {
return fmt.Errorf("organization has no custom domain")
}
domain := &coredata.CustomDomain{}
if err := domain.LoadByID(ctx, tx, scope, *org.CustomDomainID); err != nil {
return fmt.Errorf("cannot load domain: %w", err)
}
if err := domain.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete domain: %w", err)
}
org.CustomDomainID = nil
if err := org.Update(ctx, scope, tx); err != nil {
return fmt.Errorf("cannot update organization: %w", err)
}
return nil
},
)
}
func (s *CustomDomainService) GetOrganizationCustomDomain(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
) (*coredata.CustomDomain, error) {
var domain *coredata.CustomDomain
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var org coredata.Organization
if err := org.LoadByID(ctx, conn, scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if org.CustomDomainID == nil {
return nil
}
domain = &coredata.CustomDomain{}
if err := domain.LoadByID(ctx, conn, scope, *org.CustomDomainID); err != nil {
return fmt.Errorf("cannot load custom domain: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return domain, nil
}

View File

@@ -34,9 +34,6 @@ const (
ScopeV1CommonThirdPartyRead coredata.OAuth2Scope = "v1:common-third-party:read"
ScopeV1CommonThirdParty coredata.OAuth2Scope = "v1:common-third-party"
ScopeV1CompliancePageRead coredata.OAuth2Scope = "v1:compliance-page:read"
ScopeV1CompliancePage coredata.OAuth2Scope = "v1:compliance-page"
ScopeV1ConnectorRead coredata.OAuth2Scope = "v1:connector:read"
ScopeV1Connector coredata.OAuth2Scope = "v1:connector"
@@ -119,79 +116,6 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionCommonThirdPartyGet,
ActionCommonThirdPartyList,
},
ScopeV1CompliancePageRead: {
ActionTrustCenterGet,
ActionTrustCenterGetNda,
ActionTrustCenterAccessGet,
ActionTrustCenterAccessList,
ActionTrustCenterFileGet,
ActionTrustCenterFileList,
ActionTrustCenterFileGetFileUrl,
ActionTrustCenterReferenceList,
ActionTrustCenterReferenceGetLogoUrl,
ActionTrustCenterDocumentAccessList,
ActionMailingListUpdateList,
ActionMailingListSubscriberList,
ActionComplianceFrameworkList,
ActionComplianceExternalURLList,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
ActionCustomDomainGet,
},
ScopeV1CompliancePage: {
ActionTrustCenterGet,
ActionTrustCenterGetNda,
ActionTrustCenterAccessGet,
ActionTrustCenterAccessList,
ActionTrustCenterFileGet,
ActionTrustCenterFileList,
ActionTrustCenterFileGetFileUrl,
ActionTrustCenterReferenceList,
ActionTrustCenterReferenceGetLogoUrl,
ActionTrustCenterDocumentAccessList,
ActionMailingListUpdateList,
ActionMailingListSubscriberList,
ActionComplianceFrameworkList,
ActionComplianceExternalURLList,
ActionCompliancePortalCommitmentGroupList,
ActionCompliancePortalCommitmentList,
ActionCustomDomainGet,
ActionTrustCenterUpdate,
ActionTrustCenterNonDisclosureAgreementUpload,
ActionTrustCenterNonDisclosureAgreementDelete,
ActionTrustCenterAccessCreate,
ActionTrustCenterAccessUpdate,
ActionTrustCenterAccessDelete,
ActionTrustCenterFileUpdate,
ActionTrustCenterFileDelete,
ActionTrustCenterFileCreate,
ActionTrustCenterReferenceCreate,
ActionTrustCenterReferenceUpdate,
ActionTrustCenterReferenceDelete,
ActionMailingListUpdateCreate,
ActionMailingListUpdateUpdate,
ActionMailingListUpdateSend,
ActionMailingListUpdateDelete,
ActionMailingListUpdate,
ActionMailingListSubscriberCreate,
ActionMailingListSubscriberDelete,
ActionComplianceFrameworkCreate,
ActionComplianceFrameworkDelete,
ActionComplianceFrameworkUpdateRank,
ActionComplianceExternalURLCreate,
ActionComplianceExternalURLUpdate,
ActionComplianceExternalURLDelete,
ActionCompliancePortalCommitmentGroupCreate,
ActionCompliancePortalCommitmentGroupUpdate,
ActionCompliancePortalCommitmentGroupUpdateRank,
ActionCompliancePortalCommitmentGroupDelete,
ActionCompliancePortalCommitmentCreate,
ActionCompliancePortalCommitmentUpdate,
ActionCompliancePortalCommitmentUpdateRank,
ActionCompliancePortalCommitmentDelete,
ActionCustomDomainCreate,
ActionCustomDomainDelete,
},
ScopeV1ConnectorRead: {
ActionConnectorList,
ActionConnectorGet,

View File

@@ -102,17 +102,6 @@ var ViewerPolicy = policy.NewPolicy(
ActionRiskAssessmentScenarioGet, ActionRiskAssessmentScenarioList,
).WithSID("entity-read-access").When(organizationCondition),
policy.Allow(
ActionTrustCenterGet,
ActionTrustCenterAccessGet, ActionTrustCenterAccessList,
ActionTrustCenterDocumentAccessList,
ActionTrustCenterFileGet, ActionTrustCenterFileList, ActionTrustCenterFileGetFileUrl,
ActionTrustCenterReferenceList, ActionTrustCenterReferenceGetLogoUrl,
ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList,
ActionComplianceFrameworkList,
).WithSID("trust-center-read-access").When(organizationCondition),
policy.Allow(ActionCustomDomainGet).WithSID("custom-domain-read").When(organizationCondition),
policy.Allow(ActionOrganizationContextGet).WithSID("organization-context-read").When(organizationCondition),
policy.Allow(
ActionDocumentVersionExportPDF, ActionDocumentVersionSign,

View File

@@ -28,7 +28,6 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
@@ -83,7 +82,6 @@ type (
llmClient *llm.Client
llmConfig LLMConfig
html2pdfConverter *html2pdf.Converter
acmeService *certmanager.ACMEService
fileManager *filemanager.Service
logger *log.Logger
slack *slack.Service
@@ -110,14 +108,6 @@ type (
Data *DatumService
Audits *AuditService
WebhookSubscriptions *WebhookSubscriptionService
TrustCenters *TrustCenterService
TrustCenterAccesses *TrustCenterAccessService
TrustCenterReferences *TrustCenterReferenceService
CompliancePortalCommitmentGroups *CompliancePortalCommitmentGroupService
CompliancePortalCommitments *CompliancePortalCommitmentService
TrustCenterFiles *TrustCenterFileService
ComplianceFrameworks *ComplianceFrameworkService
ComplianceExternalURLs *ComplianceExternalURLService
Findings *FindingService
Obligations *ObligationService
RightsRequests *RightsRequestService
@@ -127,7 +117,6 @@ type (
StatementsOfApplicability *StatementOfApplicabilityService
GeneratedDocuments *GeneratedDocumentService
Files *FileService
CustomDomains *CustomDomainService
SlackMessages *slack.Service
}
)
@@ -143,7 +132,6 @@ func NewService(
llmClient *llm.Client,
llmConfig LLMConfig,
html2pdfConverter *html2pdf.Converter,
acmeService *certmanager.ACMEService,
fileManagerService *filemanager.Service,
logger *log.Logger,
slackService *slack.Service,
@@ -168,7 +156,6 @@ func NewService(
llmClient: llmClient,
llmConfig: llmConfig,
html2pdfConverter: html2pdfConverter,
acmeService: acmeService,
fileManager: fileManagerService,
logger: logger,
slack: slackService,
@@ -234,27 +221,6 @@ func NewService(
svc.Data = &DatumService{svc: svc}
svc.Audits = &AuditService{svc: svc}
svc.WebhookSubscriptions = &WebhookSubscriptionService{svc: svc}
svc.TrustCenters = &TrustCenterService{svc: svc}
svc.TrustCenterAccesses = &TrustCenterAccessService{svc: svc}
svc.TrustCenterReferences = &TrustCenterReferenceService{svc: svc}
svc.CompliancePortalCommitmentGroups = &CompliancePortalCommitmentGroupService{svc: svc}
svc.CompliancePortalCommitments = &CompliancePortalCommitmentService{svc: svc}
svc.ComplianceFrameworks = &ComplianceFrameworkService{svc: svc}
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
svc.TrustCenterFiles = &TrustCenterFileService{
svc: svc,
fileValidator: filevalidation.NewValidator(
filevalidation.WithCategories(
filevalidation.CategoryData,
filevalidation.CategoryDocument,
filevalidation.CategoryImage,
filevalidation.CategoryPresentation,
filevalidation.CategorySpreadsheet,
filevalidation.CategoryText,
),
filevalidation.WithMaxFileSize(10*1024*1024), // 10MB
),
}
svc.Findings = &FindingService{svc: svc}
svc.Obligations = &ObligationService{svc: svc}
svc.RightsRequests = &RightsRequestService{svc: svc}
@@ -264,12 +230,6 @@ func NewService(
svc.StatementsOfApplicability = &StatementOfApplicabilityService{svc: svc}
svc.GeneratedDocuments = &GeneratedDocumentService{svc: svc}
svc.Files = &FileService{svc: svc}
svc.CustomDomains = &CustomDomainService{
svc: svc,
encryptionKey: encryptionKey,
acmeService: acmeService,
logger: logger.Named("custom_domains"),
}
svc.SlackMessages = slackService
return svc, nil
@@ -406,28 +366,3 @@ func (s *Service) commitSuccessfulExport(ctx context.Context, exportJob *coredat
},
)
}
func (s *Service) LoadOrganizationByDomain(ctx context.Context, domain string) (gid.GID, error) {
var organizationID gid.GID
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var customDomain coredata.CustomDomain
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {
return fmt.Errorf("cannot load custom domain: %w", err)
}
var org coredata.Organization
if err := org.LoadByCustomDomainID(ctx, conn, coredata.NewNoScope(), customDomain.ID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
organizationID = org.ID
return nil
},
)
return organizationID, err
}

View File

@@ -1,420 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/validator"
)
type (
TrustCenterAccessService struct {
svc *Service
}
CreateTrustCenterAccessRequest struct {
TrustCenterID gid.GID
IdentityID gid.GID
}
UpdateTrustCenterDocumentAccessRequest struct {
ID gid.GID
Status coredata.TrustCenterDocumentAccessStatus
}
UpdateTrustCenterAccessRequest struct {
ID gid.GID
DocumentAccesses []UpdateTrustCenterDocumentAccessRequest
ReportAccesses []UpdateTrustCenterDocumentAccessRequest
TrustCenterFileAccesses []UpdateTrustCenterDocumentAccessRequest
}
TrustCenterAccessData struct {
TrustCenterID gid.GID `json:"trust_center_id"`
Email mail.Addr `json:"email"`
}
)
func (utcar *UpdateTrustCenterAccessRequest) Validate() error {
v := validator.New()
v.Check(utcar.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterAccessEntityType))
for i, docAccess := range utcar.DocumentAccesses {
v.Check(docAccess.ID, fmt.Sprintf("documentAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.DocumentEntityType))
}
for i, reportAccess := range utcar.ReportAccesses {
v.Check(reportAccess.ID, fmt.Sprintf("reportAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.FileEntityType))
}
for i, reportAccess := range utcar.TrustCenterFileAccesses {
v.Check(reportAccess.ID, fmt.Sprintf("trustCenterFileAccesses[%d].ID", i), validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
}
return v.Error()
}
func (s TrustCenterAccessService) ListForTrustCenterID(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.TrustCenterAccessOrderField],
) (*page.Page[*coredata.TrustCenterAccess, coredata.TrustCenterAccessOrderField], error) {
var accesses coredata.TrustCenterAccesses
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return accesses.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(accesses, cursor), nil
}
func (s TrustCenterAccessService) ListAvailableDocumentAccesses(
ctx context.Context, scope coredata.Scoper,
trustCenterAccessID gid.GID,
cursor *page.Cursor[coredata.TrustCenterDocumentAccessOrderField],
) (*page.Page[*coredata.TrustCenterDocumentAccess, coredata.TrustCenterDocumentAccessOrderField], error) {
var documentAccesses coredata.TrustCenterDocumentAccesses
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return documentAccesses.LoadAvailableByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(documentAccesses, cursor), nil
}
func (s TrustCenterAccessService) Get(
ctx context.Context, scope coredata.Scoper,
accessID gid.GID,
) (*coredata.TrustCenterAccess, error) {
var access coredata.TrustCenterAccess
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return access.LoadByID(ctx, conn, scope, accessID)
},
)
if err != nil {
return nil, err
}
return &access, nil
}
func (s TrustCenterAccessService) CountDocumentAccesses(
ctx context.Context, scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s TrustCenterAccessService) CountPendingRequestDocumentAccesses(
ctx context.Context, scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountPendingRequestByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s TrustCenterAccessService) CountActiveDocumentAccesses(
ctx context.Context, scope coredata.Scoper,
trustCenterAccessID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
documentAccesses coredata.TrustCenterDocumentAccesses
err error
)
count, err = documentAccesses.CountActiveByTrustCenterAccessID(ctx, conn, scope, trustCenterAccessID)
return err
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s TrustCenterAccessService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateTrustCenterAccessRequest,
) (*coredata.TrustCenterAccess, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var (
access *coredata.TrustCenterAccess
trustCenterAcessActivated bool
shouldUpdateSlackMessage bool
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
access = &coredata.TrustCenterAccess{}
if err := access.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
var tcdas coredata.TrustCenterDocumentAccesses
if len(req.DocumentAccesses) > 0 {
var documentData []coredata.MergeTrustCenterDocumentAccessesData
documentIDs := make([]gid.GID, 0, len(req.DocumentAccesses))
for _, d := range req.DocumentAccesses {
documentData = append(documentData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
documentIDs = append(documentIDs, d.ID)
}
documents := &coredata.Documents{}
if err := documents.LoadByIDs(ctx, tx, scope, documentIDs); err != nil {
return fmt.Errorf("cannot load documents: %w", err)
}
if err := tcdas.MergeDocumentAccesses(ctx, tx, scope, access.OrganizationID, access.ID, documentData); err != nil {
return fmt.Errorf("cannot merge document accesses: %w", err)
}
}
if len(req.ReportAccesses) > 0 {
var reportData []coredata.MergeTrustCenterDocumentAccessesData
reportIDs := make([]gid.GID, 0, len(req.ReportAccesses))
for _, d := range req.ReportAccesses {
reportData = append(reportData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
reportIDs = append(reportIDs, d.ID)
}
files := &coredata.Files{}
if err := files.LoadByIDs(ctx, tx, scope, reportIDs); err != nil {
return fmt.Errorf("cannot load report files: %w", err)
}
if err := tcdas.MergeReportFileAccesses(ctx, tx, scope, access.OrganizationID, access.ID, reportData); err != nil {
return fmt.Errorf("cannot merge report accesses: %w", err)
}
}
if len(req.TrustCenterFileAccesses) > 0 {
var fileData []coredata.MergeTrustCenterDocumentAccessesData
trustCenterFileIDs := make([]gid.GID, 0, len(req.TrustCenterFileAccesses))
for _, d := range req.TrustCenterFileAccesses {
fileData = append(fileData, coredata.MergeTrustCenterDocumentAccessesData{
ID: d.ID,
Status: d.Status,
})
trustCenterFileIDs = append(trustCenterFileIDs, d.ID)
}
trustCenterFiles := &coredata.TrustCenterFiles{}
if err := trustCenterFiles.LoadByIDs(ctx, tx, scope, trustCenterFileIDs); err != nil {
return fmt.Errorf("cannot load trust center files: %w", err)
}
if err := tcdas.MergeTrustCenterFileAccesses(ctx, tx, scope, access.OrganizationID, access.ID, fileData); err != nil {
return fmt.Errorf("cannot merge trust center file accesses: %w", err)
}
}
if trustCenterAcessActivated {
if err := s.sendAccessEmail(ctx, scope, tx, access); err != nil {
return fmt.Errorf("cannot send access email: %w", err)
}
}
shouldUpdateSlackMessage = trustCenterAcessActivated ||
len(req.DocumentAccesses) > 0 ||
len(req.ReportAccesses) > 0 ||
len(req.TrustCenterFileAccesses) > 0
return nil
},
)
if err != nil {
return nil, err
}
if shouldUpdateSlackMessage {
if err := s.svc.SlackMessages.QueueSlackNotification(ctx, scope, access.IdentityID, access.TrustCenterID); err != nil {
if !errors.Is(err, slack.ErrNoSlackConnector) {
return nil, fmt.Errorf("cannot queue slack notification: %w", err)
}
}
}
return access, nil
}
func (s TrustCenterAccessService) Delete(
ctx context.Context, scope coredata.Scoper,
trustCenterAccessID gid.GID,
) error {
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
access := &coredata.TrustCenterAccess{}
if err := access.LoadByID(ctx, tx, scope, trustCenterAccessID); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
if err := access.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center access: %w", err)
}
return nil
},
)
return err
}
func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, scope coredata.Scoper, tx pg.Tx, access *coredata.TrustCenterAccess) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, access.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
now := time.Now()
access.UpdatedAt = now
if err := access.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center access with expiration: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(
ctx,
tx,
scope,
access.IdentityID,
access.OrganizationID,
); err != nil {
return fmt.Errorf("cannot load profile: %w", err)
}
emailPresenterCfg, err := s.svc.TrustCenters.EmailPresenterConfig(ctx, scope, access.TrustCenterID)
if err != nil {
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
}
emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, profile.FullName)
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name)
if err != nil {
return fmt.Errorf("cannot render trust center access email: %w", err)
}
accessEmail := coredata.NewEmail(
profile.FullName,
profile.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organization.Name),
},
)
if err := accessEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert access email: %w", err)
}
return nil
}

View File

@@ -1,437 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
TrustCenterFileService struct {
svc *Service
fileValidator *filevalidation.FileValidator
}
CreateTrustCenterFileRequest struct {
OrganizationID gid.GID
Name string
Category string
File File
TrustCenterVisibility coredata.TrustCenterVisibility
}
UpdateTrustCenterFileRequest struct {
ID gid.GID
Name *string
Category *string
TrustCenterVisibility *coredata.TrustCenterVisibility
}
)
func (ctcfr *CreateTrustCenterFileRequest) Validate() error {
v := validator.New()
v.Check(ctcfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(ctcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcfr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcfr.File, "file", validator.Required())
v.Check(ctcfr.TrustCenterVisibility, "trust_center_visibility", validator.Required(), validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (utcfr *UpdateTrustCenterFileRequest) Validate() error {
v := validator.New()
v.Check(utcfr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
v.Check(utcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcfr.Category, "category", validator.SafeText(TitleMaxLength))
v.Check(utcfr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
return v.Error()
}
func (s TrustCenterFileService) ListForOrganizationID(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.TrustCenterFileOrderField],
filter *coredata.TrustCenterFileFilter,
) (*page.Page[*coredata.TrustCenterFile, coredata.TrustCenterFileOrderField], error) {
var files coredata.TrustCenterFiles
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := files.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
return fmt.Errorf("cannot load trust center files: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return page.NewPage(files, cursor), nil
}
func (s TrustCenterFileService) CountForOrganizationID(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
count, err = (&coredata.TrustCenterFiles{}).CountByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count trust center files: %w", err)
}
return nil
})
if err != nil {
return 0, err
}
return count, nil
}
func (s TrustCenterFileService) Get(
ctx context.Context, scope coredata.Scoper,
id gid.GID,
) (*coredata.TrustCenterFile, error) {
var file *coredata.TrustCenterFile
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
file = &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, conn, scope, id); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return file, nil
}
func (s TrustCenterFileService) Create(
ctx context.Context, scope coredata.Scoper,
req *CreateTrustCenterFileRequest,
) (*coredata.TrustCenterFile, error) {
if err := req.Validate(); err != nil {
return nil, err
}
// Validate file
filename := req.File.Filename
contentType := req.File.ContentType
fileSize, err := filemanager.GetFileSize(req.File.Content)
if err != nil {
return nil, fmt.Errorf("cannot get file size: %w", err)
}
if err := s.fileValidator.Validate(filename, contentType, fileSize); err != nil {
return nil, err
}
now := time.Now()
trustCenterFileID := gid.New(scope.GetTenantID(), coredata.TrustCenterFileEntityType)
var (
file *coredata.TrustCenterFile
s3Key string
)
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
fileID, objectKey, err := s.uploadFile(ctx, scope, tx, req.File, trustCenterFileID, req.OrganizationID, now)
if err != nil {
return fmt.Errorf("cannot upload file: %w", err)
}
s3Key = objectKey
file = &coredata.TrustCenterFile{
ID: trustCenterFileID,
OrganizationID: req.OrganizationID,
Name: req.Name,
Category: req.Category,
FileID: fileID,
TrustCenterVisibility: req.TrustCenterVisibility,
CreatedAt: now,
UpdatedAt: now,
}
if err := file.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center file: %w", err)
}
return nil
},
)
if err != nil {
s.cleanupS3Object(ctx, scope, s3Key)
return nil, err
}
return file, nil
}
func (s TrustCenterFileService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateTrustCenterFileRequest,
) (*coredata.TrustCenterFile, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
var file *coredata.TrustCenterFile
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
file = &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
if req.Name != nil {
file.Name = *req.Name
}
if req.Category != nil {
file.Category = *req.Category
}
if req.TrustCenterVisibility != nil {
file.TrustCenterVisibility = *req.TrustCenterVisibility
}
file.UpdatedAt = now
if err := file.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return file, nil
}
func (s TrustCenterFileService) Delete(
ctx context.Context, scope coredata.Scoper,
trustCenterFileID gid.GID,
) error {
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
file := &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, tx, scope, trustCenterFileID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
if err := file.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center file: %w", err)
}
return nil
})
return err
}
func (s TrustCenterFileService) GenerateFileURL(
ctx context.Context, scope coredata.Scoper,
trustCenterFileID gid.GID,
duration time.Duration,
) (string, error) {
var storedFile *coredata.File
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
file := &coredata.TrustCenterFile{}
if err := file.LoadByID(ctx, conn, scope, trustCenterFileID); err != nil {
return fmt.Errorf("cannot load trust center file: %w", err)
}
storedFile = &coredata.File{}
if err := storedFile.LoadByID(ctx, conn, scope, file.FileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
fileURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, storedFile, duration)
if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err)
}
return fileURL, nil
}
func (s TrustCenterFileService) uploadFile(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
file File,
trustCenterFileID gid.GID,
organizationID gid.GID,
now time.Time,
) (gid.GID, string, error) {
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
objectKey, err := uuid.NewV7()
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot generate object key: %w", err)
}
var (
fileSize int64
fileContent io.ReadSeeker
)
filename := file.Filename
contentType := file.ContentType
if readSeeker, ok := file.Content.(io.ReadSeeker); ok {
if file.Size <= 0 {
size, err := readSeeker.Seek(0, io.SeekEnd)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot determine file size: %w", err)
}
fileSize = size
_, err = readSeeker.Seek(0, io.SeekStart)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot reset file position: %w", err)
}
} else {
fileSize = file.Size
}
fileContent = readSeeker
} else {
buf, err := io.ReadAll(file.Content)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot read file: %w", err)
}
fileSize = int64(len(buf))
fileContent = bytes.NewReader(buf)
}
if contentType == "" {
contentType = "application/octet-stream"
if filename != "" {
if detectedType := mime.TypeByExtension(filepath.Ext(filename)); detectedType != "" {
contentType = detectedType
}
}
}
_, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: new(s.svc.bucket),
Key: new(objectKey.String()),
Body: fileContent,
ContentType: new(contentType),
CacheControl: new("private, max-age=3600"),
Metadata: map[string]string{
"type": "trust-center-file",
"trust-center-file-id": trustCenterFileID.String(),
"organization-id": organizationID.String(),
},
})
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot upload file to S3: %w", err)
}
fileRecord := &coredata.File{
ID: fileID,
OrganizationID: organizationID,
BucketName: s.svc.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: fileSize,
Visibility: coredata.FileVisibilityPrivate,
CreatedAt: now,
UpdatedAt: now,
}
if err := fileRecord.Insert(ctx, tx, scope); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot insert file: %w", err)
}
return fileID, objectKey.String(), nil
}
func (s TrustCenterFileService) cleanupS3Object(ctx context.Context, scope coredata.Scoper, s3Key string) {
if s3Key == "" {
return
}
_, _ = s.svc.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: new(s.svc.bucket),
Key: new(s3Key),
})
}

View File

@@ -1,433 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type (
TrustCenterReferenceService struct {
svc *Service
}
CreateTrustCenterReferenceRequest struct {
TrustCenterID gid.GID
Name string
Description *string
WebsiteURL string
LogoFile File
}
UpdateTrustCenterReferenceRequest struct {
ID gid.GID
Name *string
Description **string
WebsiteURL *string
LogoFile *File
Rank *int
}
)
func (ctcrr *CreateTrustCenterReferenceRequest) Validate() error {
v := validator.New()
v.Check(ctcrr.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(ctcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ctcrr.WebsiteURL, "website_url", validator.Required(), validator.SafeText(2048))
return v.Error()
}
func (utcrr *UpdateTrustCenterReferenceRequest) Validate() error {
v := validator.New()
v.Check(utcrr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterReferenceEntityType))
v.Check(utcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(utcrr.WebsiteURL, "website_url", validator.SafeText(2048))
return v.Error()
}
func (s TrustCenterReferenceService) ListForTrustCenterID(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
cursor *page.Cursor[coredata.TrustCenterReferenceOrderField],
) (*page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField], error) {
var references coredata.TrustCenterReferences
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
err := references.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor)
if err != nil {
return fmt.Errorf("cannot load trust center references: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return page.NewPage(references, cursor), nil
}
func (s TrustCenterReferenceService) CountForTrustCenterID(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) (err error) {
references := coredata.TrustCenterReferences{}
count, err = references.CountByTrustCenterID(ctx, conn, scope, trustCenterID)
if err != nil {
return fmt.Errorf("cannot count trust center references: %w", err)
}
return nil
})
if err != nil {
return 0, err
}
return count, nil
}
func (s TrustCenterReferenceService) Get(
ctx context.Context, scope coredata.Scoper,
referenceID gid.GID,
) (*coredata.TrustCenterReference, error) {
var reference coredata.TrustCenterReference
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
err := reference.LoadByID(ctx, conn, scope, referenceID)
if err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return &reference, nil
}
func (s TrustCenterReferenceService) Create(
ctx context.Context, scope coredata.Scoper,
req *CreateTrustCenterReferenceRequest,
) (*coredata.TrustCenterReference, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
referenceID := gid.New(scope.GetTenantID(), coredata.TrustCenterReferenceEntityType)
var reference *coredata.TrustCenterReference
var logoKey string
err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
fileID, s3Key, err := s.uploadLogoFile(ctx, scope, tx, req.LogoFile, referenceID, req.TrustCenterID, now)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
logoKey = s3Key
reference = &coredata.TrustCenterReference{
ID: referenceID,
OrganizationID: trustCenter.OrganizationID,
TrustCenterID: req.TrustCenterID,
Name: req.Name,
Description: req.Description,
WebsiteURL: req.WebsiteURL,
LogoFileID: fileID,
CreatedAt: now,
UpdatedAt: now,
}
if err := reference.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center reference: %w", err)
}
return nil
})
if err != nil {
s.cleanupS3Object(ctx, scope, logoKey)
return nil, err
}
return reference, nil
}
func (s TrustCenterReferenceService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateTrustCenterReferenceRequest,
) (*coredata.TrustCenterReference, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
var (
reference *coredata.TrustCenterReference
newFileID *gid.GID
logoKey string
)
err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
reference = &coredata.TrustCenterReference{}
if err := reference.LoadByID(ctx, tx, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
if req.LogoFile != nil {
fileID, s3Key, err := s.uploadLogoFile(ctx, scope, tx, *req.LogoFile, req.ID, reference.TrustCenterID, now)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
newFileID = &fileID
logoKey = s3Key
}
if req.Name != nil {
reference.Name = *req.Name
}
if req.Description != nil {
reference.Description = *req.Description
}
if req.WebsiteURL != nil {
reference.WebsiteURL = *req.WebsiteURL
}
if newFileID != nil {
reference.LogoFileID = *newFileID
}
reference.UpdatedAt = now
if req.Rank != nil {
reference.Rank = *req.Rank
if err := reference.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update rank: %w", err)
}
}
if err := reference.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update trust center reference: %w", err)
}
return nil
})
if err != nil {
s.cleanupS3Object(ctx, scope, logoKey)
return nil, err
}
return reference, nil
}
func (s TrustCenterReferenceService) Delete(
ctx context.Context, scope coredata.Scoper,
trustCenterReferenceID gid.GID,
) error {
err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
reference := &coredata.TrustCenterReference{}
if err := reference.LoadByID(ctx, tx, scope, trustCenterReferenceID); err != nil {
return fmt.Errorf("cannot load trust center reference: %w", err)
}
if err := reference.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete trust center reference: %w", err)
}
return nil
})
return err
}
func (s TrustCenterReferenceService) GenerateLogoURL(
ctx context.Context,
scope coredata.Scoper,
referenceID gid.GID,
) (string, error) {
reference := &coredata.TrustCenterReference{}
err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return reference.LoadByID(ctx, tx, scope, referenceID)
})
if err != nil {
return "", fmt.Errorf("cannot load trust center reference: %w", err)
}
file, err := s.svc.fileManager.GetPublicFile(ctx, reference.LogoFileID)
if err != nil {
return "", err
}
return s.svc.fileManager.GenerateFileURL(file), nil
}
func (s TrustCenterReferenceService) uploadLogoFile(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
file File,
referenceID gid.GID,
trustCenterID gid.GID,
now time.Time,
) (gid.GID, string, error) {
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
objectKey, err := uuid.NewV7()
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot generate object key: %w", err)
}
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, scope, trustCenterID); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot load trust center: %w", err)
}
var (
fileSize int64
fileContent io.ReadSeeker
)
filename := file.Filename
contentType := file.ContentType
if readSeeker, ok := file.Content.(io.ReadSeeker); ok {
if file.Size <= 0 {
size, err := readSeeker.Seek(0, io.SeekEnd)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot determine file size: %w", err)
}
fileSize = size
_, err = readSeeker.Seek(0, io.SeekStart)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot reset file position: %w", err)
}
} else {
fileSize = file.Size
}
fileContent = readSeeker
} else {
buf, err := io.ReadAll(file.Content)
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot read file: %w", err)
}
fileSize = int64(len(buf))
fileContent = bytes.NewReader(buf)
}
if contentType == "" {
contentType = "application/octet-stream"
if filename != "" {
if detectedType := mime.TypeByExtension(filepath.Ext(filename)); detectedType != "" {
contentType = detectedType
}
}
}
_, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: new(s.svc.bucket),
Key: new(objectKey.String()),
Body: fileContent,
ContentType: new(contentType),
CacheControl: new("max-age=3600, public"),
Metadata: map[string]string{
"type": "trust-center-reference-logo",
"trust-center-reference-id": referenceID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
})
if err != nil {
return gid.GID{}, "", fmt.Errorf("cannot upload logo file to S3: %w", err)
}
fileRecord := &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.svc.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: fileSize,
Visibility: coredata.FileVisibilityPublic,
CreatedAt: now,
UpdatedAt: now,
}
if err := fileRecord.Insert(ctx, tx, scope); err != nil {
return gid.GID{}, "", fmt.Errorf("cannot insert file: %w", err)
}
return fileID, objectKey.String(), nil
}
func (s TrustCenterReferenceService) cleanupS3Object(ctx context.Context, scope coredata.Scoper, s3Key string) {
if s3Key == "" {
return
}
_, _ = s.svc.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: new(s.svc.bucket),
Key: new(s3Key),
})
}

View File

@@ -1,717 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/url"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
type (
TrustCenterService struct {
svc *Service
}
UpdateTrustCenterRequest struct {
ID gid.GID
Active *bool
Slug *string
SearchEngineIndexing *coredata.SearchEngineIndexing
NonDisclosureAgreementFileID *gid.GID
}
UploadTrustCenterNDARequest struct {
TrustCenterID gid.GID
File io.Reader
FileName string
}
UpdateTrustCenterBrandRequest struct {
TrustCenterID gid.GID
LogoFile **FileUpload
DarkLogoFile **FileUpload
}
)
const maxBrandFileSize = 5 * 1024 * 1024 // 5MB
func (utcr *UpdateTrustCenterRequest) Validate() error {
v := validator.New()
v.Check(utcr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(utcr.Slug, "slug", validator.SafeText(NameMaxLength))
v.Check(utcr.NonDisclosureAgreementFileID, "non_disclosure_agreement_file_id", validator.GID(coredata.FileEntityType))
return v.Error()
}
func (utcndar *UploadTrustCenterNDARequest) Validate() error {
v := validator.New()
v.Check(utcndar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(utcndar.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}
func (req *UpdateTrustCenterBrandRequest) Validate() error {
fv := filevalidation.NewValidator(
filevalidation.WithCategories(filevalidation.CategoryImage),
filevalidation.WithMaxFileSize(maxBrandFileSize),
)
if req.LogoFile != nil && *req.LogoFile != nil {
logoFile := *req.LogoFile
if err := fv.Validate(logoFile.Filename, logoFile.ContentType, logoFile.Size); err != nil {
return fmt.Errorf("invalid logo file: %w", err)
}
}
if req.DarkLogoFile != nil && *req.DarkLogoFile != nil {
darkLogoFile := *req.DarkLogoFile
if err := fv.Validate(darkLogoFile.Filename, darkLogoFile.ContentType, darkLogoFile.Size); err != nil {
return fmt.Errorf("invalid dark logo file: %w", err)
}
}
return nil
}
func (s TrustCenterService) Get(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.TrustCenter, error) {
var trustCenter *coredata.TrustCenter
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot load trust center: %w", err)
}
return trustCenter, nil
}
func (s TrustCenterService) GetByOrganizationID(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
) (*coredata.TrustCenter, error) {
var trustCenter *coredata.TrustCenter
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByOrganizationID(ctx, conn, scope, organizationID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return trustCenter, nil
}
func (s TrustCenterService) Update(
ctx context.Context, scope coredata.Scoper,
req *UpdateTrustCenterRequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
file *coredata.File
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.ID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if req.Active != nil {
trustCenter.Active = *req.Active
}
if req.Slug != nil {
trustCenter.Slug = *req.Slug
}
if req.SearchEngineIndexing != nil {
trustCenter.SearchEngineIndexing = *req.SearchEngineIndexing
}
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID != nil {
file = &coredata.File{}
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, file, nil
}
func (s TrustCenterService) UploadNDA(
ctx context.Context, scope coredata.Scoper,
req *UploadTrustCenterNDARequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
file *coredata.File
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.OrganizationID == gid.Nil {
return fmt.Errorf("trust center %s has no organization", req.TrustCenterID)
}
objectKey, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("cannot generate object key: %w", err)
}
mimeType := mime.TypeByExtension(filepath.Ext(req.FileName))
if mimeType == "" {
mimeType = "application/octet-stream"
}
now := time.Now()
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
file = &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.svc.bucket,
MimeType: mimeType,
FileName: req.FileName,
FileKey: objectKey.String(),
Visibility: coredata.FileVisibilityPrivate,
CreatedAt: now,
UpdatedAt: now,
}
fileSize, err := s.svc.fileManager.PutFile(
ctx,
file,
req.File,
map[string]string{
"type": "trust-center-nda",
"trust-center-id": req.TrustCenterID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
)
if err != nil {
return fmt.Errorf("cannot upload file to S3: %w", err)
}
file.FileSize = fileSize
if err := file.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert file: %w", err)
}
trustCenter.NonDisclosureAgreementFileID = &fileID
trustCenter.UpdatedAt = now
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, file, nil
}
func (s TrustCenterService) DeleteNDA(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.TrustCenter, *coredata.File, error) {
var trustCenter *coredata.TrustCenter
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
trustCenter.NonDisclosureAgreementFileID = nil
trustCenter.UpdatedAt = time.Now()
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, nil, nil
}
func (s TrustCenterService) UpdateTrustCenterBrand(
ctx context.Context, scope coredata.Scoper,
req *UpdateTrustCenterBrandRequest,
) (*coredata.TrustCenter, *coredata.File, error) {
if err := req.Validate(); err != nil {
return nil, nil, err
}
var (
trustCenter *coredata.TrustCenter
ndaFile *coredata.File
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, req.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
now := time.Now()
if req.LogoFile != nil {
if *req.LogoFile == nil {
trustCenter.LogoFileID = nil
} else {
file, err := s.uploadFile(ctx, scope, conn, *req.LogoFile, "trust-center-logo", trustCenter)
if err != nil {
return fmt.Errorf("cannot upload logo file: %w", err)
}
trustCenter.LogoFileID = &file.ID
}
}
if req.DarkLogoFile != nil {
if *req.DarkLogoFile == nil {
trustCenter.DarkLogoFileID = nil
} else {
file, err := s.uploadFile(ctx, scope, conn, *req.DarkLogoFile, "trust-center-dark-logo", trustCenter)
if err != nil {
return fmt.Errorf("cannot upload dark logo file: %w", err)
}
trustCenter.DarkLogoFileID = &file.ID
}
}
trustCenter.UpdatedAt = now
if err := trustCenter.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID != nil {
ndaFile = &coredata.File{}
if err := ndaFile.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load nda file: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return trustCenter, ndaFile, nil
}
func (s TrustCenterService) uploadFile(
ctx context.Context, scope coredata.Scoper,
conn pg.Tx,
fileUpload *FileUpload,
fileType string,
trustCenter *coredata.TrustCenter,
) (*coredata.File, error) {
objectKey, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("cannot generate object key: %w", err)
}
mimeType := fileUpload.ContentType
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(fileUpload.Filename))
}
_, err = s.svc.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.svc.bucket,
Key: new(objectKey.String()),
Body: fileUpload.Content,
ContentType: &mimeType,
CacheControl: new("max-age=3600, public"),
Metadata: map[string]string{
"type": fileType,
"trust-center-id": trustCenter.ID.String(),
"organization-id": trustCenter.OrganizationID.String(),
},
})
if err != nil {
return nil, fmt.Errorf("cannot upload file to S3: %w", err)
}
headOutput, err := s.svc.s3.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: new(s.svc.bucket),
Key: new(objectKey.String()),
})
if err != nil {
return nil, fmt.Errorf("cannot get object metadata: %w", err)
}
now := time.Now()
fileID := gid.New(scope.GetTenantID(), coredata.FileEntityType)
file := &coredata.File{
ID: fileID,
OrganizationID: trustCenter.OrganizationID,
BucketName: s.svc.bucket,
MimeType: mimeType,
FileName: fileUpload.Filename,
FileKey: objectKey.String(),
FileSize: *headOutput.ContentLength,
Visibility: coredata.FileVisibilityPublic,
CreatedAt: now,
UpdatedAt: now,
}
if err := file.Insert(ctx, conn, scope); err != nil {
return nil, fmt.Errorf("cannot insert file: %w", err)
}
return file, nil
}
func (s TrustCenterService) GenerateNDAFileURL(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
expiresIn time.Duration,
) (*string, error) {
var file *coredata.File
trustCenter := &coredata.TrustCenter{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil
}
file = &coredata.File{}
if err := file.LoadByID(ctx, conn, scope, *trustCenter.NonDisclosureAgreementFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if trustCenter.NonDisclosureAgreementFileID == nil {
return nil, nil
}
presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s TrustCenterService) GenerateLogoURL(
ctx context.Context, scope coredata.Scoper,
compliancePageID gid.GID,
expiresIn time.Duration,
) (*string, error) {
file := &coredata.File{}
compliancePage := &coredata.TrustCenter{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.LogoFileID == nil {
return nil
}
if err := file.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if compliancePage.LogoFileID == nil {
return nil, nil
}
if file.FileKey == "" {
return nil, nil
}
presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s TrustCenterService) GenerateDarkLogoURL(
ctx context.Context, scope coredata.Scoper,
compliancePageID gid.GID,
expiresIn time.Duration,
) (*string, error) {
file := &coredata.File{}
compliancePage := &coredata.TrustCenter{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.DarkLogoFileID == nil {
return nil
}
if err := file.LoadByID(ctx, conn, scope, *compliancePage.DarkLogoFileID); err != nil {
return fmt.Errorf("cannot load file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if compliancePage.DarkLogoFileID == nil {
return nil, nil
}
if file.FileKey == "" {
return nil, nil
}
presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, scope coredata.Scoper, compliancePageID gid.GID) (emails.PresenterConfig, error) {
var (
compliancePage = &coredata.TrustCenter{}
organization = &coredata.Organization{}
customDomain *coredata.CustomDomain
logoFile = &coredata.File{}
emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL)
)
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := compliancePage.LoadByID(ctx, conn, scope, compliancePageID); err != nil {
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.LogoFileID != nil {
if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil {
return fmt.Errorf("cannot load logoFile: %w", err)
}
}
if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
customDomain = &coredata.CustomDomain{}
if err := customDomain.LoadByOrganizationID(ctx, conn, scope, organization.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load custom domain: %w", err)
}
}
return nil
},
)
if err != nil {
return emailPresenterCfg, err
}
parsedBaseURL, err := url.Parse(s.svc.baseURL)
if err != nil {
return emailPresenterCfg, fmt.Errorf("cannot parse base URL: %w", err)
}
baseURL := url.URL{
Scheme: parsedBaseURL.Scheme,
Host: parsedBaseURL.Host,
Path: "/trust/" + compliancePage.ID.String(),
}
if customDomain != nil && customDomain.SSLStatus == coredata.CustomDomainSSLStatusActive {
baseURL.Host = customDomain.Domain
baseURL.Scheme = "https"
baseURL.Path = ""
}
emailPresenterCfg.BaseURL = baseURL.String()
if compliancePage.LogoFileID != nil {
if logoFile.FileKey == "" {
return emailPresenterCfg, nil
}
emailPresenterCfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
emailPresenterCfg.SenderCompanyName = organization.Name
if organization.WebsiteURL != nil {
emailPresenterCfg.SenderCompanyWebsiteURL = *organization.WebsiteURL
}
if organization.HeadquarterAddress != nil {
emailPresenterCfg.SenderCompanyHeadquarterAddress = *organization.HeadquarterAddress
}
}
return emailPresenterCfg, nil
}
func (s *TrustCenterService) GetMailingList(
ctx context.Context, scope coredata.Scoper,
trustCenterID gid.GID,
) (*coredata.MailingList, error) {
var mailingList *coredata.MailingList
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, conn, scope, trustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
if trustCenter.MailingListID == nil {
return nil
}
mailingList = &coredata.MailingList{}
if err := mailingList.LoadByID(ctx, conn, scope, *trustCenter.MailingListID); err != nil {
return fmt.Errorf("cannot load mailing list: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return mailingList, nil
}