add asset edit, rename/move and replace

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-10-06 21:24:19 +02:00
1 parent 04afdc1712
commit 8580530c34
7 files changed
+964 -38

No files matched your search

+7
View File
@@ -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).
+192
View File
@@ -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) {
+371
View File
@@ -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
}
+54
View File
@@ -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
+328 -38
View File
@@ -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)}