Serve brand assets as static files via /api/files/v1/static instead of S3
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -18,59 +18,33 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
texttemplate "text/template"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/filevalidation"
|
||||
"go.probo.inc/probo/pkg/brand"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var Templates embed.FS
|
||||
|
||||
var (
|
||||
//go:embed assets
|
||||
staticAssets embed.FS
|
||||
|
||||
staticAssetsValidator = filevalidation.NewValidator(
|
||||
filevalidation.WithMaxFileSize(5*1024*1024),
|
||||
filevalidation.WithCategories(
|
||||
filevalidation.CategoryImage,
|
||||
filevalidation.CategoryVideo,
|
||||
),
|
||||
)
|
||||
|
||||
staticAssetsDuration = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
Asset struct {
|
||||
Name string
|
||||
ObjectKey string
|
||||
BucketName string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
PresenterConfig struct {
|
||||
APIBaseURL string
|
||||
BaseURL string
|
||||
PoweredByLogo Asset
|
||||
PoweredByLogoPath string
|
||||
SenderCompanyName string
|
||||
SenderCompanyWebsiteURL string
|
||||
SenderCompanyLogo Asset
|
||||
SenderCompanyLogoPath string
|
||||
SenderCompanyHeadquarterAddress string
|
||||
}
|
||||
|
||||
CommonVariables struct {
|
||||
// Static variables
|
||||
APIBaseURL string
|
||||
BaseURL string
|
||||
PoweredByLogoURL string
|
||||
SenderCompanyName string
|
||||
@@ -83,131 +57,37 @@ type (
|
||||
}
|
||||
|
||||
Presenter struct {
|
||||
fm *filemanager.Service
|
||||
config PresenterConfig
|
||||
RecipientFullName string
|
||||
}
|
||||
)
|
||||
|
||||
func (a *Asset) GetObjectKey() string {
|
||||
return a.ObjectKey
|
||||
}
|
||||
|
||||
func (a *Asset) GetName() string {
|
||||
return a.Name
|
||||
}
|
||||
|
||||
func (a *Asset) GetBucketName() string {
|
||||
return a.BucketName
|
||||
}
|
||||
|
||||
func (a *Asset) GetMimeType() string {
|
||||
return a.MimeType
|
||||
}
|
||||
|
||||
var _ filemanager.File = (*Asset)(nil)
|
||||
|
||||
func DefaultPresenterConfig(staticAssetsBucket string, baseURL string) PresenterConfig {
|
||||
func DefaultPresenterConfig(baseURL string) PresenterConfig {
|
||||
return PresenterConfig{
|
||||
BaseURL: baseURL,
|
||||
PoweredByLogo: Asset{
|
||||
Name: "probo-gray-small.png",
|
||||
ObjectKey: "probo-gray-small.png",
|
||||
BucketName: staticAssetsBucket,
|
||||
MimeType: "image/png",
|
||||
},
|
||||
SenderCompanyName: "Probo",
|
||||
SenderCompanyWebsiteURL: "https://www.getprobo.com",
|
||||
SenderCompanyLogo: Asset{
|
||||
Name: "probo.png",
|
||||
ObjectKey: "probo.png",
|
||||
BucketName: staticAssetsBucket,
|
||||
MimeType: "image/png",
|
||||
},
|
||||
APIBaseURL: baseURL, // always API base URL
|
||||
BaseURL: baseURL, // can change to custom domain when needed
|
||||
PoweredByLogoPath: brand.DefaultPoweredByLogoPath,
|
||||
SenderCompanyName: "Probo",
|
||||
SenderCompanyWebsiteURL: "https://www.getprobo.com",
|
||||
SenderCompanyLogoPath: brand.DefaultSenderCompanyLogoPath,
|
||||
SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US",
|
||||
}
|
||||
}
|
||||
|
||||
func NewPresenterFromConfig(fileService *filemanager.Service, cfg PresenterConfig, fullName string) *Presenter {
|
||||
func NewPresenterFromConfig(cfg PresenterConfig, fullName string) *Presenter {
|
||||
return &Presenter{
|
||||
fm: fileService,
|
||||
config: cfg,
|
||||
RecipientFullName: fullName,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPresenter(fileService *filemanager.Service, staticAssetsBucket string, baseURL string, fullName string) *Presenter {
|
||||
func NewPresenter(baseURL string, fullName string) *Presenter {
|
||||
return NewPresenterFromConfig(
|
||||
fileService,
|
||||
DefaultPresenterConfig(staticAssetsBucket, baseURL),
|
||||
DefaultPresenterConfig(baseURL),
|
||||
fullName,
|
||||
)
|
||||
}
|
||||
|
||||
func UploadStaticAssets(ctx context.Context, s3Client *s3.Client, staticAssetsBucket string) error {
|
||||
subFS, err := fs.Sub(staticAssets, "assets")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create subtree file system: %w", err)
|
||||
}
|
||||
|
||||
err = fs.WalkDir(subFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot get dir entry info: %w", err)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(info.Name())
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
|
||||
if err := staticAssetsValidator.Validate(info.Name(), mimeType, info.Size()); err != nil {
|
||||
return fmt.Errorf("cannot validate file: %w", err)
|
||||
}
|
||||
|
||||
file, err := subFS.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
_, err = s3Client.PutObject(
|
||||
ctx,
|
||||
&s3.PutObjectInput{
|
||||
Bucket: new(staticAssetsBucket),
|
||||
Key: new(path),
|
||||
Body: file,
|
||||
Metadata: map[string]string{
|
||||
"type": "static-email-asset",
|
||||
},
|
||||
ContentType: new(mimeType),
|
||||
CacheControl: new("max-age=3600, public"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upload file to S3: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate asset URLs: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
subjectConfirmEmail = "Confirm your email address"
|
||||
subjectPasswordReset = "Reset your password"
|
||||
@@ -255,18 +135,13 @@ var (
|
||||
mailingListUpdatesTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-updates.txt.tmpl"))
|
||||
)
|
||||
|
||||
func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) {
|
||||
poweredByLogoURL, err := p.fm.GenerateFileUrl(ctx, &p.config.PoweredByLogo, staticAssetsDuration)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate probo logo URL: %w", err)
|
||||
}
|
||||
|
||||
senderCompanyLogoURL, err := p.fm.GenerateFileUrl(ctx, &p.config.SenderCompanyLogo, staticAssetsDuration)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate sender logo URL: %w", err)
|
||||
}
|
||||
func (p *Presenter) getCommonVariables() (*CommonVariables, error) {
|
||||
apiBaseURL := baseurl.MustParse(p.config.APIBaseURL)
|
||||
poweredByLogoURL := apiBaseURL.AppendPath(p.config.PoweredByLogoPath).MustString()
|
||||
senderCompanyLogoURL := apiBaseURL.AppendPath(p.config.SenderCompanyLogoPath).MustString()
|
||||
|
||||
return &CommonVariables{
|
||||
APIBaseURL: p.config.APIBaseURL,
|
||||
BaseURL: p.config.BaseURL,
|
||||
PoweredByLogoURL: poweredByLogoURL,
|
||||
SenderCompanyName: p.config.SenderCompanyName,
|
||||
@@ -278,7 +153,7 @@ func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, e
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderConfirmEmail(ctx context.Context, confirmationURLPath string, confirmationTokenParam string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -303,7 +178,7 @@ func (p *Presenter) RenderConfirmEmail(ctx context.Context, confirmationURLPath
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -328,7 +203,7 @@ func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPat
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath string, invitationToken string, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -361,7 +236,7 @@ func (p *Presenter) RenderDocumentApproval(
|
||||
organizationName string,
|
||||
documentName string,
|
||||
) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -394,7 +269,7 @@ func (p *Presenter) RenderDocumentSigning(
|
||||
signingURLQuery url.Values,
|
||||
organizationName string,
|
||||
) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -420,7 +295,7 @@ func (p *Presenter) RenderDocumentSigning(
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderDocumentExport(ctx context.Context, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -439,7 +314,7 @@ func (p *Presenter) RenderDocumentExport(ctx context.Context, downloadUrl string
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderFrameworkExport(ctx context.Context, downloadUrl string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -458,7 +333,7 @@ func (p *Presenter) RenderFrameworkExport(ctx context.Context, downloadUrl strin
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderTrustCenterAccess(ctx context.Context, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -481,7 +356,7 @@ func (p *Presenter) RenderTrustCenterDocumentAccessRejected(
|
||||
fileNames []string,
|
||||
organizationName string,
|
||||
) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -502,7 +377,7 @@ func (p *Presenter) RenderTrustCenterDocumentAccessRejected(
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string, tokenString string, tokenDuration time.Duration, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -525,7 +400,7 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentName string, subject string) (textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -546,7 +421,7 @@ func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, si
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderMailingListSubscription(ctx context.Context, organizationName string, confirmURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -572,7 +447,7 @@ func (p *Presenter) RenderMailingListSubscription(ctx context.Context, organizat
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderMailingListUnsubscription(ctx context.Context, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
@@ -594,7 +469,7 @@ func (p *Presenter) RenderMailingListUnsubscription(ctx context.Context, organiz
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderMailingListNews(ctx context.Context, organizationName string, newsTitle string, newsBody string, compliancePageURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
vars, err := p.getCommonVariables()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
25
pkg/brand/brand.go
Normal file
25
pkg/brand/brand.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package brand
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed assets
|
||||
staticAssets embed.FS
|
||||
|
||||
Assets fs.FS
|
||||
|
||||
DefaultPoweredByLogoPath string = "/api/files/v1/static/probo-gray-small.png"
|
||||
DefaultSenderCompanyLogoPath string = "/api/files/v1/static/probo.png"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
|
||||
Assets, err = fs.Sub(staticAssets, "assets")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func (h *completionCertificateHandler) generateCertificate(
|
||||
return nil, nil, fmt.Errorf("cannot resolve presenter config: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenterFromConfig(h.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
emailPresenter := emails.NewPresenterFromConfig(presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
|
||||
docName := ref.UnrefOrZero(signature.DocumentName)
|
||||
if docName == "" {
|
||||
|
||||
@@ -464,7 +464,7 @@ func (s *Service) GenerateCertificateFileURL(
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn)
|
||||
url, err := s.fileManager.GenerateFileURL(ctx, &file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate certificate file URL: %w", err)
|
||||
}
|
||||
@@ -501,7 +501,7 @@ func (s *Service) GenerateSignatureFileURL(
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn)
|
||||
url, err := s.fileManager.GenerateFileURL(ctx, &file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate signature file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ func (s *Service) GeneratePublicPresignedURL(ctx context.Context, fileID gid.GID
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
return s.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
}
|
||||
|
||||
// GeneratePresignedURL returns a short-lived S3 presigned URL for an already-loaded file.
|
||||
// Used by the authenticated /api/files/v1/{id} HTTP handler to avoid a second DB round-trip.
|
||||
func (s *Service) GeneratePresignedURL(ctx context.Context, file *coredata.File, expiresIn time.Duration) (string, error) {
|
||||
return s.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
return s.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func (s *Service) PutFile(
|
||||
return *headOutput.ContentLength, nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateFileUrl(
|
||||
func (s *Service) GenerateFileURL(
|
||||
ctx context.Context,
|
||||
file File,
|
||||
expiresIn time.Duration,
|
||||
|
||||
@@ -129,7 +129,7 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req
|
||||
return fmt.Errorf("cannot update identity: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken)
|
||||
if err != nil {
|
||||
|
||||
@@ -314,7 +314,7 @@ func (s AuthService) SendPasswordResetInstructionByEmail(
|
||||
return fmt.Errorf("cannot load identity: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.baseURL, identity.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderPasswordReset(
|
||||
ctx,
|
||||
@@ -387,7 +387,7 @@ func (s AuthService) CreateIdentityWithPassword(
|
||||
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, req.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.baseURL, req.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderConfirmEmail(ctx, "/auth/verify-email", confirmationToken)
|
||||
if err != nil {
|
||||
@@ -594,7 +594,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
emailPresenterCfg := emails.DefaultPresenterConfig(s.bucket, s.baseURL)
|
||||
emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
|
||||
|
||||
if req.CompliancePageID != nil {
|
||||
var err error
|
||||
@@ -605,7 +605,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
|
||||
}
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenterFromConfig(s.fm, emailPresenterCfg, fullName)
|
||||
emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, fullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderMagicLink(
|
||||
ctx,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
@@ -77,7 +78,7 @@ func (s *CompliancePageService) GenerateLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.fm.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -91,7 +92,7 @@ func (s *CompliancePageService) EmailPresenterConfig(ctx context.Context, compli
|
||||
organization = &coredata.Organization{}
|
||||
customDomain *coredata.CustomDomain
|
||||
logoFile = &coredata.File{}
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.bucket, s.baseURL)
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.baseURL)
|
||||
)
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(compliancePageID)
|
||||
@@ -152,14 +153,7 @@ func (s *CompliancePageService) EmailPresenterConfig(ctx context.Context, compli
|
||||
}
|
||||
|
||||
// If logo exists, then we will brand the emails with the org as a sender
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogo = emails.Asset{
|
||||
Name: logoFile.FileName,
|
||||
ObjectKey: logoFile.FileKey,
|
||||
BucketName: logoFile.BucketName,
|
||||
MimeType: logoFile.MimeType,
|
||||
}
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
|
||||
emailPresenterCfg.SenderCompanyName = organization.Name
|
||||
|
||||
if organization.WebsiteURL != nil {
|
||||
|
||||
@@ -514,7 +514,7 @@ func (s *OrganizationService) InviteUser(
|
||||
return fmt.Errorf("cannot generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, profile.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.baseURL, profile.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderInvitation(
|
||||
ctx,
|
||||
@@ -1409,7 +1409,7 @@ func (s OrganizationService) GenerateLogoURL(
|
||||
return nil, fmt.Errorf("cannot generate logo URL: %w", err)
|
||||
}
|
||||
|
||||
presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.fm.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -1455,7 +1455,7 @@ func (s OrganizationService) GenerateHorizontalLogoURL(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.fm.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
@@ -60,7 +61,7 @@ func (s *Service) mailingListEmailConfig(
|
||||
organization = &coredata.Organization{}
|
||||
customDomain *coredata.CustomDomain
|
||||
logoFile = &coredata.File{}
|
||||
defaultCfg = emails.DefaultPresenterConfig(s.bucket, s.apiBaseURL.String())
|
||||
defaultCfg = emails.DefaultPresenterConfig(s.apiBaseURL.String())
|
||||
)
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(mailingListID)
|
||||
@@ -128,7 +129,7 @@ func (s *Service) presenterConfigFromTrustCenter(
|
||||
customDomain *coredata.CustomDomain,
|
||||
logoFile *coredata.File,
|
||||
) (emails.PresenterConfig, string, error) {
|
||||
cfg := emails.DefaultPresenterConfig(s.bucket, s.apiBaseURL.String())
|
||||
cfg := emails.DefaultPresenterConfig(s.apiBaseURL.String())
|
||||
|
||||
compliancePageBase := s.apiBaseURL.WithPath("/trust/" + compliancePage.ID.String())
|
||||
|
||||
@@ -149,12 +150,7 @@ func (s *Service) presenterConfigFromTrustCenter(
|
||||
cfg.BaseURL = compliancePageURL
|
||||
|
||||
if compliancePage.LogoFileID != nil && logoFile != nil && logoFile.FileKey != "" {
|
||||
cfg.SenderCompanyLogo = emails.Asset{
|
||||
Name: logoFile.FileName,
|
||||
ObjectKey: logoFile.FileKey,
|
||||
BucketName: logoFile.BucketName,
|
||||
MimeType: logoFile.MimeType,
|
||||
}
|
||||
cfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
|
||||
|
||||
cfg.SenderCompanyName = organization.Name
|
||||
if organization.WebsiteURL != nil {
|
||||
|
||||
@@ -722,7 +722,7 @@ func (s *Service) CreateUpdateEmails(
|
||||
return fmt.Errorf("cannot generate unsubscribe URL: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, sub.FullName).
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(presenterCfg, sub.FullName).
|
||||
RenderMailingListNews(ctx, orgName, updateTitle, updateBody, compliancePageURL, unsubscribeURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render mailing list update email: %w", err)
|
||||
@@ -776,7 +776,7 @@ func (s *Service) buildConfirmationMail(
|
||||
return nil, fmt.Errorf("cannot get subscription confirmation email config: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, fullName).
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(presenterCfg, fullName).
|
||||
RenderMailingListSubscription(ctx, orgName, confirmURL, unsubscribeURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot render subscription confirmation email: %w", err)
|
||||
@@ -807,7 +807,7 @@ func (s *Service) buildUnsubscriptionMail(
|
||||
return nil, fmt.Errorf("cannot get unsubscription email config: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, fullName).
|
||||
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(presenterCfg, fullName).
|
||||
RenderMailingListUnsubscription(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot render unsubscription email: %w", err)
|
||||
|
||||
@@ -820,7 +820,7 @@ func (s *DocumentApprovalService) sendApprovalEmails(
|
||||
|
||||
approvalEmails := make(coredata.Emails, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, profile.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.svc.baseURL, profile.FullName)
|
||||
|
||||
var (
|
||||
emailLinkURLPath = approvalURLPath
|
||||
|
||||
@@ -743,7 +743,7 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
}
|
||||
|
||||
for _, signatory := range signatories {
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, signatory.FullName)
|
||||
emailPresenter := emails.NewPresenter(s.svc.baseURL, signatory.FullName)
|
||||
|
||||
var (
|
||||
employeeDocumentsURLPath = "/organizations/" + organizationID.String() + "/employee"
|
||||
@@ -2629,7 +2629,7 @@ func (s *DocumentService) SendExportEmail(
|
||||
return fmt.Errorf("cannot generate download URL: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, recipientName)
|
||||
emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentExport(
|
||||
ctx,
|
||||
|
||||
@@ -202,7 +202,7 @@ func (s FileService) GenerateFileURL(
|
||||
return "", fmt.Errorf("cannot get file: %w", err)
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ func (s FrameworkService) SendExportEmail(
|
||||
return fmt.Errorf("cannot generate download URL: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, recipientName)
|
||||
emailPresenter := emails.NewPresenter(s.svc.baseURL, recipientName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderFrameworkExport(
|
||||
ctx,
|
||||
@@ -866,7 +866,7 @@ func (s FrameworkService) GenerateLightLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -908,7 +908,7 @@ func (s FrameworkService) GenerateDarkLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ func (s OrganizationService) GenerateLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func (s OrganizationService) GenerateHorizontalLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, scope cor
|
||||
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, profile.FullName)
|
||||
emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, profile.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name)
|
||||
if err != nil {
|
||||
|
||||
@@ -313,7 +313,7 @@ func (s TrustCenterFileService) GenerateFileURL(
|
||||
return "", err
|
||||
}
|
||||
|
||||
fileURL, err := s.svc.fileManager.GenerateFileUrl(ctx, storedFile, duration)
|
||||
fileURL, err := s.svc.fileManager.GenerateFileURL(ctx, storedFile, duration)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -500,7 +500,7 @@ func (s TrustCenterService) GenerateNDAFileURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -546,7 +546,7 @@ func (s TrustCenterService) GenerateLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func (s TrustCenterService) GenerateDarkLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -606,7 +606,7 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, scope cor
|
||||
organization = &coredata.Organization{}
|
||||
customDomain *coredata.CustomDomain
|
||||
logoFile = &coredata.File{}
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.bucket, s.svc.baseURL)
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL)
|
||||
)
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
@@ -664,13 +664,7 @@ func (s *TrustCenterService) EmailPresenterConfig(ctx context.Context, scope cor
|
||||
return emailPresenterCfg, nil
|
||||
}
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogo = emails.Asset{
|
||||
Name: logoFile.FileName,
|
||||
ObjectKey: logoFile.FileKey,
|
||||
BucketName: logoFile.BucketName,
|
||||
MimeType: logoFile.MimeType,
|
||||
}
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
|
||||
emailPresenterCfg.SenderCompanyName = organization.Name
|
||||
|
||||
if organization.WebsiteURL != nil {
|
||||
|
||||
@@ -41,7 +41,6 @@ import (
|
||||
"go.gearno.de/kit/worker"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/accessreview"
|
||||
"go.probo.inc/probo/pkg/agentrun"
|
||||
"go.probo.inc/probo/pkg/awsconfig"
|
||||
@@ -436,14 +435,6 @@ func (impl *Implm) Run(
|
||||
}
|
||||
}
|
||||
|
||||
if err := emails.UploadStaticAssets(
|
||||
ctx,
|
||||
s3Client,
|
||||
impl.cfg.AWS.Bucket,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot upload email static assets: %w", err)
|
||||
}
|
||||
|
||||
iamService, err := iam.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
|
||||
@@ -16,11 +16,14 @@ package files_v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/brand"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/file"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
@@ -57,6 +60,7 @@ func NewMux(
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/static/{file}", h.handleGetStaticFile)
|
||||
r.Get("/public/{fileID}", h.handleGetPublicFile)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -70,6 +74,17 @@ func NewMux(
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetStaticFile(w http.ResponseWriter, r *http.Request) {
|
||||
file := chi.URLParam(r, "file")
|
||||
|
||||
if _, statErr := fs.Stat(brand.Assets, file); statErr == nil {
|
||||
http.ServeFileFS(w, r, brand.Assets, file)
|
||||
return
|
||||
}
|
||||
|
||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
|
||||
fileIDStr := chi.URLParam(r, "fileID")
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s FrameworkService) GenerateLightLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func (s FrameworkService) GenerateDarkLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ func (s *TrustCenterAccessService) sendAccessEmail(
|
||||
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, profile.FullName)
|
||||
emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, profile.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterAccess(ctx, organization.Name)
|
||||
if err != nil {
|
||||
@@ -604,7 +604,7 @@ func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail(
|
||||
return fmt.Errorf("cannot get compliance page email presenter config: %w", err)
|
||||
}
|
||||
|
||||
emailPresenter := emails.NewPresenterFromConfig(s.svc.fileManager, emailPresenterCfg, profile.FullName)
|
||||
emailPresenter := emails.NewPresenterFromConfig(emailPresenterCfg, profile.FullName)
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderTrustCenterDocumentAccessRejected(
|
||||
ctx,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
@@ -147,7 +148,7 @@ func (s TrustCenterService) GenerateNDAFileURL(
|
||||
return "", err
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -194,7 +195,7 @@ func (s TrustCenterService) GenerateLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -241,7 +242,7 @@ func (s TrustCenterService) GenerateDarkLogoURL(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileUrl(ctx, file, expiresIn)
|
||||
presignedURL, err := s.svc.fileManager.GenerateFileURL(ctx, file, expiresIn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
@@ -259,7 +260,7 @@ func (s *TrustCenterService) EmailPresenterConfig(
|
||||
organization = &coredata.Organization{}
|
||||
customDomain *coredata.CustomDomain
|
||||
logoFile = &coredata.File{}
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.bucket, s.svc.baseURL)
|
||||
emailPresenterCfg = emails.DefaultPresenterConfig(s.svc.baseURL)
|
||||
)
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
@@ -318,14 +319,7 @@ func (s *TrustCenterService) EmailPresenterConfig(
|
||||
}
|
||||
|
||||
// If logo exists, then we will brand the emails with the org as a sender
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogo = emails.Asset{
|
||||
Name: logoFile.FileName,
|
||||
ObjectKey: logoFile.FileKey,
|
||||
BucketName: logoFile.BucketName,
|
||||
MimeType: logoFile.MimeType,
|
||||
}
|
||||
|
||||
emailPresenterCfg.SenderCompanyLogoPath = filepath.Join("/api/files/v1/public/", logoFile.ID.String())
|
||||
emailPresenterCfg.SenderCompanyName = organization.Name
|
||||
|
||||
if organization.WebsiteURL != nil {
|
||||
|
||||
Reference in New Issue
Block a user