mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-09 05:47:51 +02:00
add company shared assets
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user