add company shared assets

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-18 09:41:54 +02:00
parent 1069437edf
commit 78427c7896
9 changed files with 195 additions and 27 deletions
+6 -5
View File
@@ -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,
+3
View File
@@ -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}}/<AssetsKey>/file
AssetsKey string `gorm:"unique;index"`
// backref: many-to-one
Users []*User //`gorm:"foreignKey:CompanyID;"`
+3
View File
@@ -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
+32
View File
@@ -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,
+13
View File
@@ -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,
}
}
+22
View File
@@ -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
}
+48 -14
View File
@@ -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/<slug>)
// - 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,
+6
View File
@@ -60,6 +60,12 @@
goto('/asset/shared/');
}}>Open shared assets</BigButton
>
{:else}
<BigButton
on:click={() => {
goto('/asset/company/');
}}>Open company assets</BigButton
>
{/if}
<Table
columns={[{ column: 'Name', size: 'large' }]}
@@ -27,16 +27,26 @@
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import FileField from '$lib/components/FileField.svelte';
import { onClickCopy } from '$lib/utils/common.js';
// services
const appStateService = AppStateService.instance;
// data
let domainContext = $page.params.domain === 'shared' ? '' : $page.params.domain;
// the 'company' route shows the company asset folder, which is stored under
// the shared directory keyed by the company assets slug
const isCompanyFolder = $page.params.domain === 'company';
let domainContext =
$page.params.domain === 'shared' || isCompanyFolder ? '' : $page.params.domain;
// the company assets slug, fetched on mount for company folder view
let companyAssetsKey = '';
let pathTooltip = 'Web root relative path to the file(s).';
if (!domainContext) {
if (isCompanyFolder) {
pathTooltip = 'Reference as {{.BaseURL}}/<key>/<path> in templates and emails.';
} else if (!domainContext) {
pathTooltip = 'Web root relative path to the file(s) on any domain.';
}
let contextCompanyID = '';
@@ -79,6 +89,11 @@
if (context) {
contextCompanyID = context.companyID ?? '';
}
// the company folder only exists within a company context
if (isCompanyFolder && !contextCompanyID) {
goto('/asset');
return;
}
// if were have a domain context but are in
refreshAssets();
redirectIfWrongContext();
@@ -88,6 +103,27 @@
};
});
const loadCompanyAssetsKey = async () => {
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 @@
};
</script>
<HeadTitle title="Assets ({$page.params.domain})" />
<HeadTitle title="Assets ({isCompanyFolder ? 'company' : $page.params.domain})" />
<main>
<Headline>
Assets: <span class="select-all">{$page.params.domain}</span>
{#if isCompanyFolder}
Company assets
{:else}
Assets: <span class="select-all">{$page.params.domain}</span>
{/if}
</Headline>
<BigButton on:click={openCreateModal}>New asset</BigButton>
<Table
@@ -455,6 +502,13 @@
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickPreview(asset.path)} />
{#if isCompanyFolder}
<TableDropDownButton
name="Copy path"
title="Copy template reference"
on:click={() => onClickCopyPath(asset.path)}
/>
{/if}
<TableUpdateButton
on:click={() => onClickEdit(asset.id)}
{...globalButtonDisabledAttributes(asset, contextCompanyID)}