mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-08 00:16:54 +02:00
add asset edit, rename/move and replace
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
7 files changed
+964
-38
No files matched your search
@@ -207,6 +207,9 @@ const (
|
||||
ROUTE_V1_ASSET_DOMAIN_CONTEXT = "/api/v1/asset/domain/:domain"
|
||||
ROUTE_V1_ASSET_GLOBAL_CONTEXT = "/api/v1/asset/domain/"
|
||||
ROUTE_V1_ASSET_DOMAIN_VIEW = "/api/v1/asset/view/domain/:domain/*path"
|
||||
ROUTE_V1_ASSET_ID_CONTENT = "/api/v1/asset/:id/content"
|
||||
ROUTE_V1_ASSET_ID_MOVE = "/api/v1/asset/:id/move"
|
||||
ROUTE_V1_ASSET_ID_FILE = "/api/v1/asset/:id/file"
|
||||
// attachments
|
||||
ROUTE_V1_ATTACHMENT = "/api/v1/attachment"
|
||||
ROUTE_V1_ATTACHMENT_ID = "/api/v1/attachment/:id"
|
||||
@@ -568,6 +571,10 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
|
||||
GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID).
|
||||
PATCH(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.UpdateByID).
|
||||
GET(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.GetEditableContentByID).
|
||||
PUT(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.SaveContentByID).
|
||||
PATCH(ROUTE_V1_ASSET_ID_MOVE, middleware.SessionHandler, controllers.Asset.MoveByID).
|
||||
POST(ROUTE_V1_ASSET_ID_FILE, middleware.SessionHandler, controllers.Asset.ReplaceFileByID).
|
||||
GET(ROUTE_V1_ASSET_DOMAIN_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
|
||||
GET(ROUTE_V1_ASSET_GLOBAL_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
|
||||
POST(ROUTE_V1_ASSET, middleware.SessionHandler, controllers.Asset.Create).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -482,6 +483,197 @@ func (a *Asset) UpdateByID(g *gin.Context) {
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// GetEditableContentByID returns an asset's content and whether it can be
|
||||
// edited as text in the editor.
|
||||
func (a *Asset) GetEditableContentByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// cap the read at the configured upload size so a huge asset is not loaded
|
||||
// into memory just to answer an edit request
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
maxBytes := int64(64 << 20)
|
||||
// only a positive option overrides the fallback, so a "0" or negative option
|
||||
// cannot disable the cap and allow an unbounded read.
|
||||
if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 {
|
||||
maxBytes = int64(maxMB) * 1024 * 1024
|
||||
}
|
||||
// get the content
|
||||
ctx := g.Request.Context()
|
||||
content, editable, err := a.AssetService.GetContentByID(ctx, session, id, maxBytes)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{
|
||||
"content": base64.StdEncoding.EncodeToString(content),
|
||||
"editable": editable,
|
||||
})
|
||||
}
|
||||
|
||||
// fileMissing answers 404 when the error is a backing file that is gone (a DB
|
||||
// row whose file was removed), instead of letting it fall through to a 500.
|
||||
func (a *Asset) fileMissing(g *gin.Context, err error) bool {
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
a.Response.NotFound(g)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveContentByID overwrites a text asset's content from the editor.
|
||||
func (a *Asset) SaveContentByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// load the size limit first so the request body can be capped before it is
|
||||
// read into memory, instead of only checking the size after buffering it all.
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
// cap the body generously above the content limit since JSON adds quoting and
|
||||
// escaping; the exact size is still checked below. a malformed option falls
|
||||
// back to a large cap so valid saves are not blocked at this stage.
|
||||
bodyLimit := int64(64 << 20)
|
||||
if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 {
|
||||
bodyLimit = int64(maxMB)*1024*1024 + (1 << 20)
|
||||
}
|
||||
g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, bodyLimit)
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if ok := a.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
content := []byte(req.Content)
|
||||
okSize, err := utils.CompareFileSizeFromString(int64(len(content)), maxFile.Value.String())
|
||||
if err != nil {
|
||||
a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err)
|
||||
a.Response.ServerErrorMessage(g, "upload size limit is misconfigured")
|
||||
return
|
||||
}
|
||||
if !okSize {
|
||||
a.Response.ValidationFailed(g, "Content", fmt.Errorf("content is too large"))
|
||||
return
|
||||
}
|
||||
// save the content
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.SaveContentByID(ctx, session, id, content)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// MoveByID renames or moves an asset to a new path within its context.
|
||||
func (a *Asset) MoveByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if ok := a.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
path, err := vo.NewRelativeFilePath(req.Path)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to parse path", "error", err)
|
||||
a.Response.ValidationFailed(g, "Path", err)
|
||||
return
|
||||
}
|
||||
// move the asset
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.MoveByID(ctx, session, id, nullable.NewNullableWithValue(*path))
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// ReplaceFileByID replaces an asset's file content with an uploaded file.
|
||||
func (a *Asset) ReplaceFileByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, err := g.FormFile("file")
|
||||
if err != nil {
|
||||
a.Logger.Debugw("no file provided", "error", err)
|
||||
a.Response.BadRequestMessage(g, "No file selected")
|
||||
return
|
||||
}
|
||||
// enforce the max file size option
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
okSize, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
|
||||
if err != nil {
|
||||
a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err)
|
||||
a.Response.ServerErrorMessage(g, "upload size limit is misconfigured")
|
||||
return
|
||||
}
|
||||
if !okSize {
|
||||
a.Response.ValidationFailed(
|
||||
g,
|
||||
"File",
|
||||
fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)),
|
||||
)
|
||||
return
|
||||
}
|
||||
// replace the file
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.ReplaceFileByID(ctx, session, id, file)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// RemoveByID removes an static asset
|
||||
// if the asset is a directory, it will be removed recursively
|
||||
func (a *Asset) RemoveByID(g *gin.Context) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
|
||||
@@ -22,6 +26,31 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// textEditableExtensions are the file extensions whose content can be loaded
|
||||
// into the asset editor and saved back as UTF-8 text.
|
||||
var textEditableExtensions = map[string]bool{
|
||||
".html": true,
|
||||
".htm": true,
|
||||
".xhtml": true,
|
||||
".txt": true,
|
||||
".css": true,
|
||||
".js": true,
|
||||
".mjs": true,
|
||||
".json": true,
|
||||
".xml": true,
|
||||
".svg": true,
|
||||
".md": true,
|
||||
".csv": true,
|
||||
".yml": true,
|
||||
".yaml": true,
|
||||
}
|
||||
|
||||
// isTextEditablePath reports whether a path points at a file that can be
|
||||
// edited as text, based on its extension.
|
||||
func isTextEditablePath(p string) bool {
|
||||
return textEditableExtensions[strings.ToLower(filepath.Ext(p))]
|
||||
}
|
||||
|
||||
// Asset is a Asset service
|
||||
type Asset struct {
|
||||
Common
|
||||
@@ -809,3 +838,345 @@ func (a *Asset) DeleteAllByDomainID(
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openContextRoot opens a root confined to the folder the asset is stored in.
|
||||
// The caller is responsible for closing both returned roots.
|
||||
func (a *Asset) openContextRoot(
|
||||
ctx context.Context,
|
||||
asset *model.Asset,
|
||||
) (*os.Root, *os.Root, error) {
|
||||
domainContext, err := a.assetContextFolder(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := os.OpenRoot(a.RootFolder)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to open root folder", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
contextRoot, err := root.OpenRoot(domainContext)
|
||||
if err != nil {
|
||||
root.Close()
|
||||
a.Logger.Debugw("failed to open context", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return root, contextRoot, nil
|
||||
}
|
||||
|
||||
// GetContentByID returns an asset's file content and whether it can be edited
|
||||
// as text. Content is read through a root confined to the asset folder.
|
||||
func (a *Asset) GetContentByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
maxBytes int64,
|
||||
) ([]byte, bool, error) {
|
||||
ae := NewAuditEvent("Asset.GetContentById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return nil, false, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return nil, false, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return nil, false, errs.Wrap(err)
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
f, err := contextRoot.Open(p.String())
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to open asset file", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
// refuse to load a file larger than the limit so a huge asset is not read into
|
||||
// memory (and base64 inflated) just to answer an edit request.
|
||||
if maxBytes > 0 {
|
||||
if info, serr := f.Stat(); serr == nil && info.Size() > maxBytes {
|
||||
return nil, false, errs.NewValidationError(fmt.Errorf("file is too large to edit"))
|
||||
}
|
||||
}
|
||||
// bound the read as a safeguard even if the Stat above was skipped or lied
|
||||
var reader io.Reader = f
|
||||
if maxBytes > 0 {
|
||||
reader = io.LimitReader(f, maxBytes)
|
||||
}
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to read asset file", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
editable := isTextEditablePath(p.String()) && utf8.Valid(content)
|
||||
// no audit on read
|
||||
return content, editable, nil
|
||||
}
|
||||
|
||||
// SaveContentByID overwrites an existing text asset's file content. Only UTF-8
|
||||
// text files with an editable extension can be saved this way.
|
||||
func (a *Asset) SaveContentByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
content []byte,
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.SaveContentById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
// only editable text files can be written through the editor
|
||||
if !isTextEditablePath(p.String()) {
|
||||
return errs.NewValidationError(fmt.Errorf("file is not an editable text file"))
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return errs.NewValidationError(fmt.Errorf("content is not valid UTF-8 text"))
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the file must already exist, editing never creates a new asset
|
||||
if _, err := contextRoot.Stat(p.String()); err != nil {
|
||||
a.Logger.Debugw("asset file not found", "path", p.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
if err := a.FileService.UploadFile(contextRoot, p.String(), bytes.NewBuffer(content), true); err != nil {
|
||||
a.Logger.Errorw("failed to write asset content", "error", err)
|
||||
return err
|
||||
}
|
||||
// bump updated_at
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil {
|
||||
a.Logger.Errorw("failed to update asset timestamp", "error", err)
|
||||
return err
|
||||
}
|
||||
ae.Details["path"] = p.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveByID renames or moves an asset to a new path within its current context
|
||||
// folder. It does not change the asset owner (domain or company).
|
||||
func (a *Asset) MoveByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
newPath nullable.Nullable[vo.RelativeFilePath],
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.MoveById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
oldPath, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
np, err := newPath.Get()
|
||||
if err != nil {
|
||||
return validate.WrapErrorWithField(errs.NewValidationError(err), "Path")
|
||||
}
|
||||
// ensure the destination path is safe to use
|
||||
cleaned := strings.TrimPrefix(np.String(), "/")
|
||||
if cleaned == "" || strings.Contains(cleaned, "..") || strings.HasPrefix(cleaned, "/") {
|
||||
a.Logger.Warnw("insecure path", "path", cleaned)
|
||||
return validate.WrapErrorWithField(
|
||||
errs.NewValidationError(fmt.Errorf("invalid path: %s", cleaned)),
|
||||
"Path",
|
||||
)
|
||||
}
|
||||
dst, err := vo.NewRelativeFilePath(cleaned)
|
||||
if err != nil {
|
||||
return validate.WrapErrorWithField(errs.NewValidationError(err), "Path")
|
||||
}
|
||||
// nothing to do if the path is unchanged
|
||||
if dst.String() == oldPath.String() {
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
domainContext, err := a.assetContextFolder(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the source must exist
|
||||
if _, err := contextRoot.Stat(oldPath.String()); err != nil {
|
||||
a.Logger.Debugw("source file not found", "path", oldPath.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
// create destination directories through the root
|
||||
dstDir := filepath.Dir(dst.String())
|
||||
if dstDir != "." {
|
||||
if err := contextRoot.MkdirAll(dstDir, 0755); err != nil {
|
||||
a.Logger.Errorw("failed to create destination directory", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
// move with no overwrite: Link creates the destination only when it does not
|
||||
// already exist, failing with EEXIST otherwise, so a move can never replace an
|
||||
// existing file, even if one appears between a check and the move. the source
|
||||
// is then unlinked. Rename is avoided because it silently overwrites the
|
||||
// destination. both paths stay confined to the asset context root.
|
||||
if err := contextRoot.Link(oldPath.String(), dst.String()); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return errs.NewValidationError(fmt.Errorf("file already exists: %s", dst.String()))
|
||||
}
|
||||
a.Logger.Errorw("failed to move asset file", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := contextRoot.Remove(oldPath.String()); err != nil {
|
||||
// nothing visible changed yet; drop the new link so no duplicate is left.
|
||||
_ = contextRoot.Remove(dst.String())
|
||||
a.Logger.Errorw("failed to remove old asset file after move", "error", err)
|
||||
return err
|
||||
}
|
||||
// update the path in the database. if this fails, move the file back so the
|
||||
// filesystem and the database never disagree about where the asset lives.
|
||||
update := &model.Asset{Path: nullable.NewNullableWithValue(*dst)}
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, update); err != nil {
|
||||
a.Logger.Errorw("failed to update asset path, rolling back the file move", "error", err)
|
||||
if rbErr := contextRoot.Link(dst.String(), oldPath.String()); rbErr != nil {
|
||||
a.Logger.Errorw("failed to restore asset file after a failed move, manual fix needed",
|
||||
"from", dst.String(), "to", oldPath.String(), "error", rbErr)
|
||||
} else {
|
||||
_ = contextRoot.Remove(dst.String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
// remove directories left empty by the move. this is cosmetic and runs only
|
||||
// after the database agrees with the filesystem, so a failure here does not
|
||||
// fail the move or leave the two out of sync.
|
||||
oldFullPath := filepath.Join(a.RootFolder, domainContext, oldPath.String())
|
||||
if err := a.FileService.RemoveEmptyFolderRecursively(
|
||||
filepath.Join(a.RootFolder, domainContext),
|
||||
filepath.Dir(oldFullPath),
|
||||
); err != nil {
|
||||
a.Logger.Debugw("failed to remove empty folders after move", "error", err)
|
||||
}
|
||||
ae.Details["from"] = oldPath.String()
|
||||
ae.Details["to"] = dst.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceFileByID overwrites an existing asset's file content with an uploaded
|
||||
// file, keeping the asset's path and filename.
|
||||
func (a *Asset) ReplaceFileByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
file *multipart.FileHeader,
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.ReplaceFileById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the file must already exist, replacing never creates a new asset
|
||||
if _, err := contextRoot.Stat(p.String()); err != nil {
|
||||
a.Logger.Debugw("asset file not found", "path", p.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to open uploaded file", "error", err)
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err != nil {
|
||||
a.Logger.Errorw("failed to read uploaded file", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := a.FileService.UploadFile(contextRoot, p.String(), &buf, true); err != nil {
|
||||
a.Logger.Errorw("failed to write asset content", "error", err)
|
||||
return err
|
||||
}
|
||||
// bump updated_at
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil {
|
||||
a.Logger.Errorw("failed to update asset timestamp", "error", err)
|
||||
return err
|
||||
}
|
||||
ae.Details["path"] = p.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
@@ -362,6 +362,60 @@ export class API {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get an asset's content in base64 and whether it is editable as text
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getContent: async (id) => {
|
||||
return await getJSON(this.getPath(`/asset/${id}/content`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Save an asset's text content
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} content
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
saveContent: async (id, content) => {
|
||||
return await putJSON(this.getPath(`/asset/${id}/content`), {
|
||||
content: content
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename or move an asset to a new path within its context
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} path
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
move: async (id, path) => {
|
||||
return await patchJSON(this.getPath(`/asset/${id}/move`), {
|
||||
path: path
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace an asset's file with an uploaded file
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {FormData} data form data with a 'file' field
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
replaceFile: async (id, data) => {
|
||||
const res = await fetch(this.getPath(`/asset/${id}/file`), {
|
||||
method: 'POST',
|
||||
// content-type is set automatically by the browser
|
||||
body: data
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all assets for a domain using pagination.
|
||||
*
|
||||
|
||||
@@ -639,6 +639,12 @@
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual
|
||||
// save shortcut maps to the page's save action.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
editorContainer?.closest('form')?.requestSubmit();
|
||||
});
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
editor.getModel().onDidChangeContent((e) => {
|
||||
|
||||
@@ -130,6 +130,12 @@
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual
|
||||
// save shortcut maps to the page's save action.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
editorContainer?.closest('form')?.requestSubmit();
|
||||
});
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
// Update value when editor content changes
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -30,6 +31,9 @@
|
||||
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import FileField from '$lib/components/FileField.svelte';
|
||||
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
|
||||
import Editor from '$lib/components/editor/Editor.svelte';
|
||||
import { BiMap } from '$lib/utils/maps.js';
|
||||
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
|
||||
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
|
||||
import {
|
||||
@@ -119,6 +123,7 @@
|
||||
// if were have a domain context but are in
|
||||
refreshAssets();
|
||||
redirectIfWrongContext();
|
||||
loadDomainMap();
|
||||
tableURLParams.onChange(refreshAssets);
|
||||
return () => {
|
||||
tableURLParams.unsubscribe();
|
||||
@@ -210,15 +215,32 @@
|
||||
|
||||
const update = async () => {
|
||||
try {
|
||||
// 1. name and description
|
||||
const res = await api.asset.update(formValues.id, formValues.name, formValues.description);
|
||||
if (res.success) {
|
||||
addToast('Updated asset', 'Success');
|
||||
refreshAssets();
|
||||
closeModal();
|
||||
return;
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
throw res.error;
|
||||
}
|
||||
modalError = res.error;
|
||||
throw res.error;
|
||||
// 2. content, for a text file edited in the same modal. runs before the move
|
||||
// so the text check uses the current extension.
|
||||
if (editContentEditable) {
|
||||
const cr = await api.asset.saveContent(formValues.id, editContent.value);
|
||||
if (!cr.success) {
|
||||
modalError = cr.error;
|
||||
throw cr.error;
|
||||
}
|
||||
}
|
||||
// 3. rename / move, only when the path changed
|
||||
if (formValues.path && formValues.path !== editOriginalPath) {
|
||||
const mr = await api.asset.move(formValues.id, formValues.path);
|
||||
if (!mr.success) {
|
||||
modalError = mr.error;
|
||||
throw mr.error;
|
||||
}
|
||||
}
|
||||
addToast('Updated asset', 'Success');
|
||||
refreshAssets();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
addToast('Failed to update asset', 'Error');
|
||||
console.error('failed to update asset', e);
|
||||
@@ -261,32 +283,54 @@
|
||||
const closeModal = () => {
|
||||
modalError = '';
|
||||
isModalVisible = false;
|
||||
editContentEditable = false;
|
||||
editContent = { id: '', path: '', value: '', language: 'plaintext' };
|
||||
editIsHtml = false;
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const openCreateModal = async () => {
|
||||
modalMode = 'create';
|
||||
// start clean so a previously edited asset's path does not leak in
|
||||
formValues = { id: '', name: '', description: '', path: '' };
|
||||
editContentEditable = false;
|
||||
editIsHtml = false;
|
||||
editOriginalPath = '';
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* Open the edit modal: name and description, plus the content editor for a text file.
|
||||
* @param {*} asset
|
||||
*/
|
||||
const onClickEdit = async (id) => {
|
||||
const onClickEdit = async (asset) => {
|
||||
modalMode = 'update';
|
||||
// get the asset
|
||||
editContentEditable = false;
|
||||
editContent = { id: asset.id, path: asset.path, value: '', language: 'plaintext' };
|
||||
editIsHtml = /\.(html?|xhtml)$/i.test(asset.path);
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.asset.getByID(id);
|
||||
const res = await api.asset.getByID(asset.id);
|
||||
if (!res.success) {
|
||||
addToast('Failed to get asset', 'Error');
|
||||
console.error('failed to get asset', res.error);
|
||||
return;
|
||||
}
|
||||
isModalVisible = true;
|
||||
formValues.id = res.data.id;
|
||||
formValues.name = res.data.name;
|
||||
formValues.description = res.data.description;
|
||||
formValues.path = res.data.path;
|
||||
editOriginalPath = res.data.path;
|
||||
// for a text file, load its content so the same modal can edit it
|
||||
if (isTextEditable(asset.path)) {
|
||||
const cr = await api.asset.getContent(asset.id);
|
||||
if (cr.success && cr.data.editable) {
|
||||
editContentEditable = true;
|
||||
editContent.value = decodeBase64Utf8(cr.data.content);
|
||||
editContent.language = languageForPath(asset.path);
|
||||
}
|
||||
}
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to get asset', 'Error');
|
||||
console.error('failed to get asset', e);
|
||||
@@ -295,6 +339,150 @@
|
||||
}
|
||||
};
|
||||
|
||||
// text content editing, folded into the edit modal for text files
|
||||
let editContent = { id: '', path: '', value: '', language: 'plaintext' };
|
||||
let editContentEditable = false;
|
||||
// an HTML asset uses the same editor as a landing page, with its live domain
|
||||
// preview; other text files use a plain code editor
|
||||
let editIsHtml = false;
|
||||
// domains for the editor's preview dropdown, same source as the page editor
|
||||
let domainMap = new BiMap({});
|
||||
|
||||
const loadDomainMap = async () => {
|
||||
try {
|
||||
const domains = await fetchAllRows((options) =>
|
||||
api.domain.getAllSubsetWithoutProxies(options, contextCompanyID)
|
||||
);
|
||||
domainMap = BiMap.FromArrayOfObjects(domains);
|
||||
} catch (e) {
|
||||
console.error('failed to load domains for preview', e);
|
||||
}
|
||||
};
|
||||
|
||||
// the asset's path when the edit modal opened, to detect a rename / move on save
|
||||
let editOriginalPath = '';
|
||||
|
||||
// replace file modal
|
||||
let isReplaceVisible = false;
|
||||
let replaceValues = { id: '', path: '' };
|
||||
let replaceError = '';
|
||||
let isReplacing = false;
|
||||
let replaceForm = null;
|
||||
|
||||
// extensions whose content can be edited as text, mirrors the backend
|
||||
const textEditableExtensions = [
|
||||
'.html',
|
||||
'.htm',
|
||||
'.xhtml',
|
||||
'.txt',
|
||||
'.css',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.json',
|
||||
'.xml',
|
||||
'.svg',
|
||||
'.md',
|
||||
'.csv',
|
||||
'.yml',
|
||||
'.yaml'
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a file can be edited as text based on its extension
|
||||
* @param {string} path
|
||||
*/
|
||||
const isTextEditable = (path) => {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
const extension = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return textEditableExtensions.includes(extension);
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a file extension to a Monaco language for syntax highlighting
|
||||
* @param {string} path
|
||||
*/
|
||||
const languageForPath = (path) => {
|
||||
const extension = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
switch (extension) {
|
||||
case '.html':
|
||||
case '.htm':
|
||||
case '.xhtml':
|
||||
return 'html';
|
||||
case '.css':
|
||||
return 'css';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'javascript';
|
||||
case '.json':
|
||||
return 'json';
|
||||
case '.xml':
|
||||
case '.svg':
|
||||
return 'xml';
|
||||
case '.md':
|
||||
return 'markdown';
|
||||
case '.yml':
|
||||
case '.yaml':
|
||||
return 'yaml';
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a base64 string of UTF-8 bytes into text
|
||||
* @param {string} b64
|
||||
*/
|
||||
const decodeBase64Utf8 = (b64) => {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the replace file modal for an asset
|
||||
* @param {*} asset
|
||||
*/
|
||||
const openReplaceModal = (asset) => {
|
||||
replaceValues = { id: asset.id, path: asset.path };
|
||||
replaceError = '';
|
||||
isReplaceVisible = true;
|
||||
};
|
||||
|
||||
const submitReplace = async () => {
|
||||
try {
|
||||
isReplacing = true;
|
||||
/** @type {HTMLInputElement} */
|
||||
const fileInput = document.querySelector('#replaceFile');
|
||||
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
||||
replaceError = 'Select a file';
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
const res = await api.asset.replaceFile(replaceValues.id, formData);
|
||||
if (!res.success) {
|
||||
replaceError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Replaced asset file', 'Success');
|
||||
isReplaceVisible = false;
|
||||
if (replaceForm) {
|
||||
replaceForm.reset();
|
||||
}
|
||||
refreshAssets();
|
||||
} catch (e) {
|
||||
addToast('Failed to replace asset file', 'Error');
|
||||
console.error('failed to replace asset file', e);
|
||||
} finally {
|
||||
isReplacing = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an asset
|
||||
* @param {string} id
|
||||
@@ -551,7 +739,13 @@
|
||||
/>
|
||||
{/if}
|
||||
<TableUpdateButton
|
||||
on:click={() => onClickEdit(asset.id)}
|
||||
on:click={() => onClickEdit(asset)}
|
||||
{...globalButtonDisabledAttributes(asset, contextCompanyID)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Replace file"
|
||||
title="Replace the file content with an upload"
|
||||
on:click={() => openReplaceModal(asset)}
|
||||
{...globalButtonDisabledAttributes(asset, contextCompanyID)}
|
||||
/>
|
||||
<TableDeleteButton
|
||||
@@ -564,42 +758,138 @@
|
||||
{/each}
|
||||
</Table>
|
||||
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
{#if modalMode === 'create'}
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
|
||||
{#if modalMode === 'update' && editContentEditable && editIsHtml}
|
||||
<!-- HTML asset: the same editor as a landing page, with live domain preview -->
|
||||
<Editor
|
||||
contentType="domain"
|
||||
{domainMap}
|
||||
baseURL={domainContext || 'example.test'}
|
||||
bind:value={editContent.value}
|
||||
>
|
||||
<div class="flex w-full flex-row flex-wrap items-start gap-x-6 gap-y-2 pl-4">
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
optional={true}
|
||||
placeholder={'profile/alice'}
|
||||
toolTipText={pathTooltip}>Path</TextField
|
||||
placeholder={'profile/alice/cv.html'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
</div>
|
||||
</Editor>
|
||||
{:else if modalMode === 'update' && editContentEditable}
|
||||
<!-- non-HTML text asset: plain code editor, no preview -->
|
||||
<div class="col-start-1 col-end-4 flex w-[70vw] flex-col gap-4">
|
||||
<div class="flex w-full flex-row flex-wrap items-start gap-x-6 gap-y-2">
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
placeholder={'profile/alice/app.css'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
</div>
|
||||
<SimpleCodeEditor
|
||||
bind:value={editContent.value}
|
||||
language={editContent.language}
|
||||
height="large"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
{#if modalMode === 'create'}
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
optional={true}
|
||||
placeholder={'profile/alice'}
|
||||
toolTipText={pathTooltip}>Path</TextField
|
||||
>
|
||||
|
||||
<FileField name="files" multiple={true}>Files</FileField>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FileField name="files" multiple={true}>Files</FileField>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
placeholder={'profile/alice/cv.pdf'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
{/if}
|
||||
<FormError message={modalError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<Modal
|
||||
headerText={`Replace: ${replaceValues.path}`}
|
||||
visible={isReplaceVisible}
|
||||
onClose={() => (isReplaceVisible = false)}
|
||||
isSubmitting={isReplacing}
|
||||
>
|
||||
<FormGrid on:submit={submitReplace} bind:bindTo={replaceForm} isSubmitting={isReplacing}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<FileField name="replaceFile" multiple={false}>File</FileField>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={replaceError} />
|
||||
<FormFooter closeModal={() => (isReplaceVisible = false)} isSubmitting={isReplacing} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<DeleteAlert
|
||||
name={deleteValues.name || deleteValues.path || 'Unnamed asset'}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
|
||||
Reference in new issue
Block a user