diff --git a/backend/app/administration.go b/backend/app/administration.go index d391867a..3ded862f 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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). diff --git a/backend/controller/asset.go b/backend/controller/asset.go index 95d667ea..624140f8 100644 --- a/backend/controller/asset.go +++ b/backend/controller/asset.go @@ -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) { diff --git a/backend/service/asset.go b/backend/service/asset.go index 649a8c02..c2ba5a76 100644 --- a/backend/service/asset.go +++ b/backend/service/asset.go @@ -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 +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index f2d5010a..9325b012 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -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} + */ + 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} + */ + 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} + */ + 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} + */ + 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. * diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index 88db2680..00a2d37c 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -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) => { diff --git a/frontend/src/lib/components/editor/SimpleCodeEditor.svelte b/frontend/src/lib/components/editor/SimpleCodeEditor.svelte index 3c986d20..12fe2ce0 100644 --- a/frontend/src/lib/components/editor/SimpleCodeEditor.svelte +++ b/frontend/src/lib/components/editor/SimpleCodeEditor.svelte @@ -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 diff --git a/frontend/src/routes/asset/[domain]/+page.svelte b/frontend/src/routes/asset/[domain]/+page.svelte index 9fc8e167..96c02490 100644 --- a/frontend/src/routes/asset/[domain]/+page.svelte +++ b/frontend/src/routes/asset/[domain]/+page.svelte @@ -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} onClickEdit(asset.id)} + on:click={() => onClickEdit(asset)} + {...globalButtonDisabledAttributes(asset, contextCompanyID)} + /> + openReplaceModal(asset)} {...globalButtonDisabledAttributes(asset, contextCompanyID)} /> - - - - Name - Description - {#if modalMode === 'create'} + + {#if modalMode === 'update' && editContentEditable && editIsHtml} + + +
+ Name + Description PathPath +
+
+ {:else if modalMode === 'update' && editContentEditable} + +
+
+ Name + Description + Path +
+ +
+ {:else} + + + Name + Description + {#if modalMode === 'create'} + Path - Files - {/if} - - + Files + {:else} + Path + {/if} +
+
+ {/if}
+ (isReplaceVisible = false)} + isSubmitting={isReplacing} + > + + + + File + + + + (isReplaceVisible = false)} isSubmitting={isReplacing} /> + + onClickDelete(deleteValues.id)}