From 78427c78966c4bdfddbb3e87d9a9be881317a0d6 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Jun 2026 09:41:54 +0200 Subject: [PATCH] add company shared assets Signed-off-by: Ronni Skansing --- backend/app/services.go | 11 +-- backend/database/company.go | 3 + backend/model/company.go | 3 + backend/repository/asset.go | 32 +++++++++ backend/repository/company.go | 13 ++++ backend/seed/migrate.go | 22 ++++++ backend/service/asset.go | 62 ++++++++++++---- frontend/src/routes/asset/+page.svelte | 6 ++ .../src/routes/asset/[domain]/+page.svelte | 70 ++++++++++++++++--- 9 files changed, 195 insertions(+), 27 deletions(-) diff --git a/backend/app/services.go b/backend/app/services.go index a73d39b..db98ee8 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -87,11 +87,12 @@ func NewServices( Common: common, } asset := &service.Asset{ - Common: common, - RootFolder: assetPath, - FileService: file, - AssetRepository: repositories.Asset, - DomainRepository: repositories.Domain, + Common: common, + RootFolder: assetPath, + FileService: file, + AssetRepository: repositories.Asset, + DomainRepository: repositories.Domain, + CompanyRepository: repositories.Company, } attachment := &service.Attachment{ Common: common, diff --git a/backend/database/company.go b/backend/database/company.go index 3b6bbfc..fae552a 100644 --- a/backend/database/company.go +++ b/backend/database/company.go @@ -18,6 +18,9 @@ type Company struct { Comment *string `gorm:"type:text"` // Color is an optional #RGB or #RRGGBB used to tint the company view banner and frame Color *string `gorm:"type:text"` + // AssetsKey is a random slug naming the company asset folder stored under the + // shared asset directory, so company assets resolve on any domain via {{.BaseURL}}//file + AssetsKey string `gorm:"unique;index"` // backref: many-to-one Users []*User //`gorm:"foreignKey:CompanyID;"` diff --git a/backend/model/company.go b/backend/model/company.go index 7c80708..8618d84 100644 --- a/backend/model/company.go +++ b/backend/model/company.go @@ -17,6 +17,9 @@ type Company struct { Name nullable.Nullable[vo.String64] `json:"name"` Comment nullable.Nullable[vo.OptionalString1MB] `json:"comment"` Color nullable.Nullable[vo.OptionalHexColor] `json:"color"` + // AssetsKey is a read only slug naming the company asset folder. It is + // generated server side and never written from client input. + AssetsKey nullable.Nullable[vo.String64] `json:"assetsKey"` } // Validate checks if the Company configuration with a valid state diff --git a/backend/repository/asset.go b/backend/repository/asset.go index de16c05..2371017 100644 --- a/backend/repository/asset.go +++ b/backend/repository/asset.go @@ -121,6 +121,38 @@ func (r *Asset) GetAllByGlobalContext( return result, nil } +// GetAllByCompanyContext gets all assets in a company folder +// these are assets owned by a company but not attached to a domain +func (r *Asset) GetAllByCompanyContext( + ctx context.Context, + companyID *uuid.UUID, + queryArgs *vo.QueryArgs, +) (*model.Result[model.Asset], error) { + result := model.NewEmptyResult[model.Asset]() + db, err := useQuery(r.DB, database.ASSET_TABLE, queryArgs, assetAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + var dbModels []*database.Asset + dbRes := db. + Where("company_id = ? AND domain_id IS NULL", companyID). + Find(&dbModels) + + if dbRes.Error != nil { + return nil, dbRes.Error + } + + hasNextPage, err := useHasNextPage(db, database.ASSET_TABLE, queryArgs, assetAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + for _, dbModel := range dbModels { + result.Rows = append(result.Rows, ToAsset(dbModel)) + } + return result, nil +} + // GetByPath gets an asset by file path func (r *Asset) GetByPath( ctx context.Context, diff --git a/backend/repository/company.go b/backend/repository/company.go index 9064d6b..07e1e4f 100644 --- a/backend/repository/company.go +++ b/backend/repository/company.go @@ -9,6 +9,7 @@ import ( "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/random" "github.com/phishingclub/phishingclub/vo" "gorm.io/gorm" ) @@ -33,6 +34,11 @@ func (r *Company) Insert( id := uuid.New() row := company.ToDBMap() row["id"] = id + assetsKey, err := random.GenerateRandomURLBase64Encoded(32) + if err != nil { + return nil, errs.Wrap(err) + } + row["assets_key"] = assetsKey AddTimestamps(row) res := r.DB. @@ -158,6 +164,12 @@ func ToCompany(row *database.Company) *model.Company { color = nullable.NewNullableWithValue(*c) } } + var assetsKey nullable.Nullable[vo.String64] + if row.AssetsKey != "" { + if k, err := vo.NewString64(row.AssetsKey); err == nil { + assetsKey = nullable.NewNullableWithValue(*k) + } + } return &model.Company{ ID: id, CreatedAt: row.CreatedAt, @@ -165,5 +177,6 @@ func ToCompany(row *database.Company) *model.Company { Name: name, Comment: comment, Color: color, + AssetsKey: assetsKey, } } diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 7fae3a6..ce80ef5 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -12,6 +12,7 @@ import ( "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/random" "go.uber.org/zap" "gorm.io/gorm" ) @@ -515,5 +516,26 @@ func migrate(db *gorm.DB) error { return errs.Wrap(err) } + // backfill companies.assets_key for rows created before the column existed + // the column and its unique index are created by AutoMigrate; here we give + // each existing company an unguessable slug for its asset folder + var companiesWithoutKey []database.Company + if err := db.Model(&database.Company{}). + Where("assets_key IS NULL OR assets_key = ''"). + Find(&companiesWithoutKey).Error; err != nil { + return errs.Wrap(err) + } + for _, c := range companiesWithoutKey { + slug, err := random.GenerateRandomURLBase64Encoded(32) + if err != nil { + return errs.Wrap(err) + } + if err := db.Model(&database.Company{}). + Where("id = ?", c.ID). + Update("assets_key", slug).Error; err != nil { + return errs.Wrap(err) + } + } + return nil } diff --git a/backend/service/asset.go b/backend/service/asset.go index 7f1a2cc..ba37fc1 100644 --- a/backend/service/asset.go +++ b/backend/service/asset.go @@ -25,10 +25,11 @@ import ( // Asset is a Asset service type Asset struct { Common - RootFolder string - FileService *File - AssetRepository *repository.Asset - DomainRepository *repository.Domain + RootFolder string + FileService *File + AssetRepository *repository.Asset + DomainRepository *repository.Domain + CompanyRepository *repository.Company } // Create creates and stores a new assets @@ -66,11 +67,10 @@ func (a *Asset) Create( files := []*RootFileUpload{} for _, asset := range assets { domainNameProvided := asset.DomainName.IsSpecified() && !asset.DomainName.IsNull() + companyProvided := asset.CompanyID.IsSpecified() && !asset.CompanyID.IsNull() // ensure context is the same across all files - if !domainNameProvided && (!asset.CompanyID.IsSpecified() || asset.CompanyID.IsNull()) { - contextFolder = data.ASSET_GLOBAL_FOLDER - } else { - // set the context folder + if domainNameProvided { + // domain context dn, err := asset.DomainName.Get() if err != nil { a.Logger.Debugw("failed to get domain name", "error", err) @@ -83,6 +83,30 @@ func (a *Asset) Create( a.Logger.Error(differentContextError) return ids, differentContextError } + } else if companyProvided { + // company folder lives under the shared directory keyed by the + // company assets slug, so it resolves on any domain via the + // existing shared asset fallback + companyID := asset.CompanyID.MustGet() + company, err := a.CompanyRepository.GetByID(g, &companyID) + if err != nil { + a.Logger.Debugw("failed to get company for asset", "error", err) + return ids, errs.Wrap(err) + } + slug, err := company.AssetsKey.Get() + if err != nil || slug.String() == "" { + a.Logger.Errorw("company has no assets key", "companyID", companyID.String()) + return ids, errs.NewCustomError(errors.New("company has no asset folder")) + } + companyFolder := filepath.Join(data.ASSET_GLOBAL_FOLDER, slug.String()) + if contextFolder == "" { + contextFolder = companyFolder + } else if contextFolder != companyFolder { + a.Logger.Error(differentContextError) + return ids, differentContextError + } + } else { + contextFolder = data.ASSET_GLOBAL_FOLDER } // map assets to files @@ -280,8 +304,12 @@ func (a *Asset) GetAll( a.AuditLogNotAuthorized(ae) return result, errs.ErrAuthorizationFailed } - // if there is no companyID or domainID then the scope is 'shared' - if companyID == nil && domainID == nil { + // scope selection: + // - no company, no domain -> global shared assets + // - company, no domain -> company folder assets (stored under shared/) + // - domain -> domain assets (incl. shared assets for that domain) + switch { + case companyID == nil && domainID == nil: result, err = a.AssetRepository.GetAllByGlobalContext( ctx, queryArgs, @@ -290,11 +318,17 @@ func (a *Asset) GetAll( a.Logger.Errorw("failed to get global asset", "error", err) return nil, errs.Wrap(err) } - } else { - if domainID == nil { - a.Logger.Errorw("domain id required", "error", errors.New("domainID is nil")) - return nil, fmt.Errorf("domain id is required") + case domainID == nil: + result, err = a.AssetRepository.GetAllByCompanyContext( + ctx, + companyID, + queryArgs, + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + a.Logger.Errorw("failed to get company assets", "error", err) + return nil, errs.Wrap(err) } + default: result, err = a.AssetRepository.GetAllByDomainAndContext( ctx, domainID, diff --git a/frontend/src/routes/asset/+page.svelte b/frontend/src/routes/asset/+page.svelte index c89e19c..c0ac259 100644 --- a/frontend/src/routes/asset/+page.svelte +++ b/frontend/src/routes/asset/+page.svelte @@ -60,6 +60,12 @@ goto('/asset/shared/'); }}>Open shared assets + {:else} + { + goto('/asset/company/'); + }}>Open company assets {/if} { + try { + const res = await api.company.getByID(contextCompanyID); + if (!res.success) { + throw res.error; + } + companyAssetsKey = res.data.assetsKey ?? ''; + } catch (e) { + addToast('Failed to load company asset folder', 'Error'); + console.error('failed to load company assets key', e); + } + }; + + /** + * Copy the template reference for a company folder asset + * @param {string} path + */ + const onClickCopyPath = (path) => { + onClickCopy(`{{.BaseURL}}/${companyAssetsKey}/${path}`); + }; + const redirectIfWrongContext = async () => { if (!domainContext || domainContext === 'shared') { return; @@ -114,6 +150,11 @@ const refreshAssets = async () => { try { isTableLoading = true; + // load the company asset slug before assets render so the company + // folder previews and copy paths resolve correctly + if (isCompanyFolder && !companyAssetsKey) { + await loadCompanyAssetsKey(); + } const res = await api.asset.getByDomain(domainContext, contextCompanyID, tableURLParams); if (!res.success) { throw res.error; @@ -260,8 +301,9 @@ }; const onClickPreview = async (path) => { - if ($page.params.domain === 'shared') { - const res = await api.asset.getRaw('shared', path); + if ($page.params.domain === 'shared' || isCompanyFolder) { + const viewPath = isCompanyFolder ? `${companyAssetsKey}/${path}` : path; + const res = await api.asset.getRaw('shared', viewPath); if (!res.success) { addToast('Failed to get asset', 'Error'); console.error('failed to get asset', res.error); @@ -295,9 +337,10 @@ * @param {string} path */ const getImagePreviewUrl = async (path) => { - if ($page.params.domain === 'shared') { + if ($page.params.domain === 'shared' || isCompanyFolder) { try { - const res = await api.asset.getRaw('shared', path); + const viewPath = isCompanyFolder ? `${companyAssetsKey}/${path}` : path; + const res = await api.asset.getRaw('shared', viewPath); if (!res.success) { return null; } @@ -378,10 +421,14 @@ }; - +
- Assets: {$page.params.domain} + {#if isCompanyFolder} + Company assets + {:else} + Assets: {$page.params.domain} + {/if} New asset
onClickPreview(asset.path)} /> + {#if isCompanyFolder} + onClickCopyPath(asset.path)} + /> + {/if} onClickEdit(asset.id)} {...globalButtonDisabledAttributes(asset, contextCompanyID)}