feat(workspace): add text editing and image previews
This commit is contained in:
@@ -79,6 +79,17 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id
|
|||||||
- POST /api/ws/rename {path,new_path}
|
- POST /api/ws/rename {path,new_path}
|
||||||
- DELETE /api/ws/delete?path=...
|
- DELETE /api/ws/delete?path=...
|
||||||
- POST /api/ws/upload multipart file,path
|
- POST /api/ws/upload multipart file,path
|
||||||
|
- GET /api/ws/image?instance=...&path=...:只读图片预览,沿用工作区认证、实例归属检查和实例读租约;仅支持 GET。
|
||||||
|
- path 与 text 接口一致:保留空白和反斜杠,必须是非空、规范的相对文件路径。Linux openat2 限制在实例 home 内,拒绝符号链接、跨挂载点、多硬链接及特殊文件;不写入文件。
|
||||||
|
- 有界读取最多 16 MiB 的内存快照,读取期间检测到文件变化返回 409,超限返回 413。PNG/JPEG/GIF/WebP 按 magic 识别;SVG 必须有 .svg 扩展名(不区分大小写),完整 XML 的唯一根元素必须是 SVG 命名空间的 svg,拒绝 HTML、XML directives/DOCTYPE 和未知实体,不加载外部实体。其他格式或目录返回 415,缺失/不安全文件返回 404,非法路径返回 400。
|
||||||
|
- 返回原始图片字节及 image MIME;固定 `X-Content-Type-Options: nosniff`、`Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`、`Content-Disposition: attachment`、`Cache-Control: no-store`。SVG 不做内容改写,依靠图片上下文和 CSP 限制脚本及外部资源;直接导航下载附件。
|
||||||
|
- GET /api/ws/text?instance=...&path=... → {content:string,version:string}
|
||||||
|
- PUT /api/ws/text?instance=...&path=... JSON {content:string,version:string} → {version:string}
|
||||||
|
- 仅编辑已有普通文件;沿用工作区认证、实例归属和恢复保护。必须提交 GET 返回的不透明 version;缺少 content/version 返回 400,版本不匹配或保存前文件已删除返回 409,不创建新文件。GET 不存在/不安全路径返回 404;非法相对路径返回 400。
|
||||||
|
- 内容最多 1 MiB(UTF-8 字节数,含边界),超限 413;无效 UTF-8、二进制/控制字符返回 415(仅允许 tab、LF、CR 控制字符)。JSON 请求上限为 6 MiB + 4096 字节以容纳转义,超限 413。
|
||||||
|
- Linux openat2 限定访问工作区内路径,拒绝符号链接、跨挂载点和多硬链接文件;不支持 openat2 时安全失败。固定父目录描述符,在同目录写临时文件、保留原文件 mode/UID/GID、同步文件,再原子 rename 替换;不保留扩展属性/ACL。审计仅记录操作和路径,不记录内容。
|
||||||
|
- 用户锁串行化面板保存;锁内读取当前配额和用量,要求现有用量 + 新内容大小不超配额(包括临时文件开销,否则 413)。因此接近配额时即使缩小文件也可能无法保存。外部进程不受该锁和配额检查约束。
|
||||||
|
- rename 前立即重新读取目标并核对最新内容/版本,但检查与 rename 不是文件系统 CAS:外部写入者仍可能在两者之间写入或删除目标,不能保证绝不覆盖外部修改。父目录被外部重命名时操作仍针对固定目录。需严格避免丢失修改时,先停止外部写入;不承诺断电后的目录项持久性。
|
||||||
- GET /api/ws/download?path=...:普通文件保持原始流与 Range 支持;目录返回 `application/zip` 附件(目录名.zip),空 path 或 `.` 下载整个工作区(workspace.zip)。ZIP 条目相对所选目录,保留空目录,不额外包裹顶层目录,使用 Store 模式(不压缩),不支持 ZIP Range。
|
- GET /api/ws/download?path=...:普通文件保持原始流与 Range 支持;目录返回 `application/zip` 附件(目录名.zip),空 path 或 `.` 下载整个工作区(workspace.zip)。ZIP 条目相对所选目录,保留空目录,不额外包裹顶层目录,使用 Store 模式(不压缩),不支持 ZIP Range。
|
||||||
- ZIP 先校验有界文件清单,再逐文件流式输出,不生成临时归档,不持 workspace/global 锁跨网络传输。每次最多 10,000 个文件/目录条目、64 层、10 GiB 文件总大小;进程最多同时 4 个目录下载,超出分别返回 413 / 429;扫描和传输合计限时 2 分钟。
|
- ZIP 先校验有界文件清单,再逐文件流式输出,不生成临时归档,不持 workspace/global 锁跨网络传输。每次最多 10,000 个文件/目录条目、64 层、10 GiB 文件总大小;进程最多同时 4 个目录下载,超出分别返回 413 / 429;扫描和传输合计限时 2 分钟。
|
||||||
- 路径必须位于工作区内;下载拒绝任意路径分量的符号链接、跨挂载点、特殊文件和多硬链接普通文件。目录含上述条目或含反斜杠/冒号的不安全 ZIP 文件名时整体拒绝(400),不静默漏文件。依赖 Linux openat2,内核不支持时安全失败。
|
- 路径必须位于工作区内;下载拒绝任意路径分量的符号链接、跨挂载点、特殊文件和多硬链接普通文件。目录含上述条目或含反斜杠/冒号的不安全 ZIP 文件名时整体拒绝(400),不静默漏文件。依赖 Linux openat2,内核不支持时安全失败。
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
|
|
||||||
## 当前实现
|
## 当前实现
|
||||||
|
|
||||||
|
文件管理中的 PNG、JPEG、GIF、WebP、SVG 可通过“预览”按钮查看(最大 16 MiB),支持适应窗口、原始尺寸、缩放和下载。预览沿用实例归属校验,SVG 只作为图片加载,响应限制脚本与外部资源;直接打开预览地址会下载文件,不作为网页执行。
|
||||||
|
|
||||||
|
文件管理支持通过“编辑”按钮在线查看和保存不超过 1 MiB 的 UTF-8 文本,使用本地 Monaco 编辑器并跟随系统深浅色。二进制或超限文件请下载处理。关闭、重新加载及离开浏览器前会提醒未保存修改;保存失败保留草稿。保存按读取版本检查冲突,不提供强制覆盖;原子替换需要额外的临时文件配额。DSH 等外部写入不受面板锁控制,最终检查与替换之间仍有竞态,修改运行配置前建议停机并备份。通用编辑不执行专用 Profile 流程的 YAML 校验或强制停机。
|
||||||
|
|
||||||
DSH 新运行镜像仅由管理员从本仓库 `dockerfile` 分支获取独立发布的构建配置,无需 CI 预构建镜像。`release.json` 和完整的 `image/` 目录仅维护在发布分支,main 不再内置运行镜像构建资产;面板锁定 Git commit,在本地构建并验证。管理员在镜像页检查并确认构建,实例升级仍需另行确认,不会因分支更新自动停机。发布约定和首次启用步骤见 [OPERATIONS.md](OPERATIONS.md)。
|
DSH 新运行镜像仅由管理员从本仓库 `dockerfile` 分支获取独立发布的构建配置,无需 CI 预构建镜像。`release.json` 和完整的 `image/` 目录仅维护在发布分支,main 不再内置运行镜像构建资产;面板锁定 Git commit,在本地构建并验证。管理员在镜像页检查并确认构建,实例升级仍需另行确认,不会因分支更新自动停机。发布约定和首次启用步骤见 [OPERATIONS.md](OPERATIONS.md)。
|
||||||
|
|
||||||
实例所有者可管理完整 home,包括读取自己的配置和凭据;不承诺密钥 write-only,跨用户隔离保持不变。通用文件操作不受专用 Profile 编辑器的 YAML 校验和停机要求约束。下载、打包与敏感修改提示用于防误操作,不是权限限制;文件下载和目录 ZIP 不会自动加密,修改配置前建议先停止实例并备份。
|
实例所有者可管理完整 home,包括读取自己的配置和凭据;不承诺密钥 write-only,跨用户隔离保持不变。通用文件操作不受专用 Profile 编辑器的 YAML 校验和停机要求约束。下载、打包与敏感修改提示用于防误操作,不是权限限制;文件下载和目录 ZIP 不会自动加密,修改配置前建议先停止实例并备份。
|
||||||
|
|||||||
+178
-1
@@ -3,8 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
@@ -15,6 +17,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
@@ -117,7 +121,7 @@ func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/api/ws/download" {
|
if r.URL.Path != "/api/ws/download" && r.URL.Path != "/api/ws/image" {
|
||||||
var flush func()
|
var flush func()
|
||||||
w, flush = deferJSONResponse(w)
|
w, flush = deferJSONResponse(w)
|
||||||
defer flush()
|
defer flush()
|
||||||
@@ -190,11 +194,184 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.wsUpload(w, r, root, u)
|
a.wsUpload(w, r, root, u)
|
||||||
case "download":
|
case "download":
|
||||||
a.wsDownload(w, r, root)
|
a.wsDownload(w, r, root)
|
||||||
|
case "image":
|
||||||
|
a.wsImage(w, r, root)
|
||||||
|
case "text":
|
||||||
|
a.wsText(w, r, root, u)
|
||||||
default:
|
default:
|
||||||
fail(w, 404, "not found")
|
fail(w, 404, "not found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxTextBytes = 1 << 20
|
||||||
|
|
||||||
|
func textStatus(content string) int {
|
||||||
|
if len(content) > maxTextBytes {
|
||||||
|
return 413
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(content) {
|
||||||
|
return 415
|
||||||
|
}
|
||||||
|
for _, c := range content {
|
||||||
|
if unicode.IsControl(c) && c != 9 && c != 10 && c != 13 {
|
||||||
|
return 415
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func textVersion(content string, st os.FileInfo) string {
|
||||||
|
s := st.Sys().(*syscall.Stat_t)
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%d:%d:%d:%d:%s", s.Dev, s.Ino, st.ModTime().UnixNano(), s.Mode, s.Uid, s.Gid, content))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readText(parent *os.File, name string) (string, string, os.FileInfo, int) {
|
||||||
|
f, err := openDownload(parent, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, 404
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
|
return "", "", nil, 415
|
||||||
|
}
|
||||||
|
if st.Size() > maxTextBytes {
|
||||||
|
return "", "", nil, 413
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(f, maxTextBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, 500
|
||||||
|
}
|
||||||
|
if code := textStatus(string(b)); code != 0 {
|
||||||
|
return "", "", nil, code
|
||||||
|
}
|
||||||
|
after, err := f.Stat()
|
||||||
|
if err != nil || st.Size() != after.Size() || !st.ModTime().Equal(after.ModTime()) || st.Sys().(*syscall.Stat_t).Ctim != after.Sys().(*syscall.Stat_t).Ctim {
|
||||||
|
return "", "", nil, 409
|
||||||
|
}
|
||||||
|
return string(b), textVersion(string(b), after), after, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) wsText(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := r.URL.Query().Get("path")
|
||||||
|
if p == "" || path.IsAbs(p) || p == "." || p == ".." || strings.HasPrefix(p, "../") || path.Clean(p) != p || strings.ContainsRune(p, 0) {
|
||||||
|
fail(w, 400, "invalid path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var x struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 6*maxTextBytes+4096))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 413, "text request too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !utf8.Valid(b) {
|
||||||
|
fail(w, 415, "invalid UTF-8")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &x) != nil || x.Content == nil || x.Version == "" {
|
||||||
|
fail(w, 400, "content and version required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code := textStatus(*x.Content); code != 0 {
|
||||||
|
fail(w, code, "unsupported text or text too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user := a.userLocks.Acquire(u.ID)
|
||||||
|
defer user.Release()
|
||||||
|
anchor, err := root.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer anchor.Close()
|
||||||
|
fd, err := unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{
|
||||||
|
Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC,
|
||||||
|
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
code := 404
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
code = 409
|
||||||
|
}
|
||||||
|
fail(w, code, "parent unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent := os.NewFile(uintptr(fd), path.Dir(p))
|
||||||
|
defer parent.Close()
|
||||||
|
content, version, st, code := readText(parent, path.Base(p))
|
||||||
|
if code != 0 {
|
||||||
|
if r.Method == http.MethodPut && code == 404 {
|
||||||
|
code = 409
|
||||||
|
}
|
||||||
|
fail(w, code, "text file unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
writeJSON(w, map[string]string{"content": content, "version": version})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if version != x.Version {
|
||||||
|
fail(w, 409, "file changed; reload before saving")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", u.ID).Scan(&u.Quota); err != nil {
|
||||||
|
fail(w, 413, "quota unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := a.userUsage(u)
|
||||||
|
if err != nil || int64(len(*x.Content)) > u.Quota-used {
|
||||||
|
fail(w, 413, "quota exceeded or usage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := ".text-" + store.ID()
|
||||||
|
tfd, err := unix.Openat2(fd, tmp, &unix.OpenHow{Flags: unix.O_WRONLY | unix.O_CREAT | unix.O_EXCL | unix.O_CLOEXEC, Mode: 0600, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS})
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "cannot prepare text save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := os.NewFile(uintptr(tfd), tmp)
|
||||||
|
defer f.Close()
|
||||||
|
defer unix.Unlinkat(fd, tmp, 0)
|
||||||
|
_, err = io.WriteString(f, *x.Content)
|
||||||
|
s := st.Sys().(*syscall.Stat_t)
|
||||||
|
if err == nil {
|
||||||
|
err = f.Chown(int(s.Uid), int(s.Gid))
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = unix.Fchmod(tfd, s.Mode&07777)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
next, statErr := f.Stat()
|
||||||
|
if err != nil || statErr != nil {
|
||||||
|
fail(w, 500, "cannot prepare text save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextVersion := textVersion(*x.Content, next)
|
||||||
|
_, latest, _, code := readText(parent, path.Base(p))
|
||||||
|
if code != 0 || latest != version {
|
||||||
|
fail(w, 409, "file changed; reload before saving")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// External writers do not take the user lock; this is not a filesystem CAS.
|
||||||
|
if err := unix.Renameat(fd, tmp, fd, path.Base(p)); err != nil {
|
||||||
|
fail(w, 500, "text save failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.s.AddAudit(u.ID, "workspace.text", p, "")
|
||||||
|
writeJSON(w, map[string]string{"version": nextVersion})
|
||||||
|
}
|
||||||
|
|
||||||
func entry(root *os.Root, rel string) (os.FileInfo, error) {
|
func entry(root *os.Root, rel string) (os.FileInfo, error) {
|
||||||
if err := rootSafe(root, rel, false); err != nil {
|
if err := rootSafe(root, rel, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
@@ -20,6 +21,188 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceText(t *testing.T) {
|
||||||
|
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||||
|
for _, i := range []store.Instance{{ID: "one", UID: "owner"}, {ID: "foreign", UID: "other"}} {
|
||||||
|
if err := a.s.AddInstance(i); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, "file")
|
||||||
|
putFile := func(content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0751); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putFile("original")
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := func(method, query, body, session string, want int) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest(method, "/api/ws/text?"+query, body, session))
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s %s: got %d want %d: %s", method, query, w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
if want == 200 {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
q := "instance=one&path=file"
|
||||||
|
body := func(content, version string) string {
|
||||||
|
b, _ := json.Marshal(map[string]string{"content": content, "version": version})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
request("GET", q, "", "", 401)
|
||||||
|
request("GET", "instance=foreign&path=file", "", sid, 404)
|
||||||
|
request("GET", "path=file", "", sid, 410)
|
||||||
|
for _, p := range []string{"../file", "..", "/etc/passwd", ".", "", "\x00", "./file", "a/../file", "a/./file", "a//file", "file/"} {
|
||||||
|
for _, method := range []string{"GET", "PUT"} {
|
||||||
|
request(method, "instance=one&path="+url.QueryEscape(p), "", sid, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ exact, counterpart string }{
|
||||||
|
{"config.yaml ", "config.yaml"},
|
||||||
|
{" config.yaml", "config.yaml"},
|
||||||
|
{`a\b`, "a/b"},
|
||||||
|
} {
|
||||||
|
for name, content := range map[string]string{tc.exact: "exact", tc.counterpart: "counterpart"} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exactQuery := "instance=one&path=" + url.QueryEscape(tc.exact)
|
||||||
|
got := request("GET", exactQuery, "", sid, 200)
|
||||||
|
if got["content"] != "exact" {
|
||||||
|
t.Fatalf("GET %q returned %q", tc.exact, got["content"])
|
||||||
|
}
|
||||||
|
request("PUT", exactQuery, body("updated", got["version"]), sid, 200)
|
||||||
|
if got := request("GET", exactQuery, "", sid, 200); got["content"] != "updated" {
|
||||||
|
t.Fatalf("GET %q after PUT returned %q", tc.exact, got["content"])
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{tc.exact: "updated", tc.counterpart: "counterpart"} {
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil || string(got) != want {
|
||||||
|
t.Fatalf("file %q: got %q, want %q: %v", name, got, want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request("POST", q, "", sid, 405)
|
||||||
|
v := request("GET", q, "", sid, 200)["version"]
|
||||||
|
if v == "" {
|
||||||
|
t.Fatal("missing version")
|
||||||
|
}
|
||||||
|
request("PUT", q, "{}", sid, 400)
|
||||||
|
request("PUT", q, body("next", "stale"), sid, 409)
|
||||||
|
request("PUT", q, body("next", ""), sid, 400)
|
||||||
|
for _, content := range []string{"nul\x00", "del\x7f", "control\x01", "invalid\xff"} {
|
||||||
|
putFile(content)
|
||||||
|
request("GET", q, "", sid, 415)
|
||||||
|
}
|
||||||
|
putFile(strings.Repeat("x", maxTextBytes+1))
|
||||||
|
request("GET", q, "", sid, 413)
|
||||||
|
putFile(strings.Repeat("x", maxTextBytes))
|
||||||
|
request("GET", q, "", sid, 200)
|
||||||
|
putFile("original")
|
||||||
|
v = request("GET", q, "", sid, 200)["version"]
|
||||||
|
request("PUT", q, body(strings.Repeat("x", maxTextBytes+1), v), sid, 413)
|
||||||
|
request("PUT", q, body("bad\x00", v), sid, 415)
|
||||||
|
request("PUT", q, "{\"content\":\"\xff\",\"version\":\"v\"}", sid, 415)
|
||||||
|
request("PUT", q, strings.Repeat(" ", 6*maxTextBytes+4097), sid, 413)
|
||||||
|
if err := os.Symlink(file, filepath.Join(dir, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(dir, filepath.Join(dir, "parent")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"link", "parent/file"} {
|
||||||
|
request("GET", "instance=one&path="+p, "", sid, 404)
|
||||||
|
request("PUT", "instance=one&path="+p, body("next", v), sid, 409)
|
||||||
|
}
|
||||||
|
if err := os.Link(file, filepath.Join(dir, "hard")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request("GET", q, "", sid, 404)
|
||||||
|
request("PUT", q, body("next", v), sid, 409)
|
||||||
|
if err := os.Remove(filepath.Join(dir, "hard")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE users SET quota=8 WHERE id='owner'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request("PUT", q, body("next", v), sid, 413)
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := os.Stat(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
next := "你好\tline\r\n"
|
||||||
|
saved := request("PUT", q, body(next, v), sid, 200)["version"]
|
||||||
|
got := request("GET", q, "", sid, 200)
|
||||||
|
if got["content"] != next || got["version"] != saved || saved == v {
|
||||||
|
t.Fatal(got, saved)
|
||||||
|
}
|
||||||
|
after, err := os.Stat(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bs, as := before.Sys().(*syscall.Stat_t), after.Sys().(*syscall.Stat_t)
|
||||||
|
if bs.Mode != as.Mode || bs.Uid != as.Uid || bs.Gid != as.Gid {
|
||||||
|
t.Fatal("metadata changed")
|
||||||
|
}
|
||||||
|
request("PUT", q, body("stale save", v), sid, 409)
|
||||||
|
results := make(chan int, 2)
|
||||||
|
for range 2 {
|
||||||
|
go func() {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest("PUT", "/api/ws/text?"+q, body("concurrent", saved), sid))
|
||||||
|
results <- w.Code
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
first, second := <-results, <-results
|
||||||
|
if !(first == 200 && second == 409 || first == 409 && second == 200) {
|
||||||
|
t.Fatalf("concurrent saves: %d %d", first, second)
|
||||||
|
}
|
||||||
|
saved = request("GET", q, "", sid, 200)["version"]
|
||||||
|
putFile("external")
|
||||||
|
request("PUT", q, body("stale save", saved), sid, 409)
|
||||||
|
if err := os.Remove(file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request("PUT", q, body("recreate", saved), sid, 409)
|
||||||
|
request("GET", q, "", sid, 404)
|
||||||
|
putFile("restored")
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='one'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request("PUT", q, body("blocked", saved), sid, 409)
|
||||||
|
request("GET", q, "", sid, 200)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasPrefix(e.Name(), ".text-") {
|
||||||
|
t.Fatal("temporary file leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUploadExtensionPolicy(t *testing.T) {
|
func TestUploadExtensionPolicy(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, policy string
|
name, policy string
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxImageBytes = 16 << 20
|
||||||
|
|
||||||
|
func (a *API) wsImage(w http.ResponseWriter, r *http.Request, root *os.Root) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := r.URL.Query().Get("path")
|
||||||
|
if p == "" || path.IsAbs(p) || p == "." || p == ".." || strings.HasPrefix(p, "../") || path.Clean(p) != p || strings.ContainsRune(p, 0) {
|
||||||
|
fail(w, 400, "invalid path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
anchor, err := root.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer anchor.Close()
|
||||||
|
f, err := openDownload(anchor, p)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 404, "image unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
|
fail(w, 415, "unsupported image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Size() > maxImageBytes {
|
||||||
|
fail(w, 413, "image too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(f, maxImageBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "image read failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(b) > maxImageBytes {
|
||||||
|
fail(w, 413, "image too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
after, err := f.Stat()
|
||||||
|
if err != nil || int64(len(b)) != st.Size() || st.Size() != after.Size() || !st.ModTime().Equal(after.ModTime()) || st.Sys().(*syscall.Stat_t).Ctim != after.Sys().(*syscall.Stat_t).Ctim {
|
||||||
|
fail(w, 409, "image changed; reload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeType := workspaceImageType(p, b)
|
||||||
|
if mimeType == "" {
|
||||||
|
fail(w, 415, "unsupported image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", mimeType)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
|
||||||
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||||
|
w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceImageType(name string, b []byte) string {
|
||||||
|
switch mimeType := http.DetectContentType(b); mimeType {
|
||||||
|
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(path.Ext(name), ".svg") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := xml.NewDecoder(bytes.NewReader(bytes.TrimPrefix(b, []byte{0xef, 0xbb, 0xbf})))
|
||||||
|
depth, roots := 0, 0
|
||||||
|
for {
|
||||||
|
token, err := d.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
if roots == 1 && depth == 0 {
|
||||||
|
return "image/svg+xml"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if depth == 0 {
|
||||||
|
roots++
|
||||||
|
if roots != 1 || t.Name.Local != "svg" || t.Name.Space != "http://www.w3.org/2000/svg" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
case xml.EndElement:
|
||||||
|
depth--
|
||||||
|
case xml.CharData:
|
||||||
|
if depth == 0 && len(bytes.TrimSpace(t)) != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
case xml.Directive:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceImage(t *testing.T) {
|
||||||
|
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||||
|
for _, i := range []store.Instance{{ID: "one", UID: "owner"}, {ID: "foreign", UID: "other"}} {
|
||||||
|
if err := a.s.AddInstance(i); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
put := func(name string, b []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), b, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request := func(method, query, session string, want int) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest(method, "/api/ws/image?"+query, "", session))
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatalf("%s %s: got %d want %d: %s", method, query, w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(a.workspaceReaders) != 0 {
|
||||||
|
t.Fatal("workspace lease leaked")
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
get := func(name string, want int) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return request("GET", "instance=one&path="+url.QueryEscape(name), sid, want)
|
||||||
|
}
|
||||||
|
request("GET", "instance=one&path=file", "", 401)
|
||||||
|
request("GET", "instance=foreign&path=file", sid, 404)
|
||||||
|
request("GET", "instance=missing&path=file", sid, 404)
|
||||||
|
request("GET", "path=file", sid, 410)
|
||||||
|
for _, method := range []string{"HEAD", "POST", "PUT", "DELETE", "PATCH"} {
|
||||||
|
request(method, "instance=one&path=file", sid, 405)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"", ".", "..", "../file", "/etc/passwd", "a/../file", "./file", "a//file", "file/", "a/./file", "a\x00b"} {
|
||||||
|
get(p, 400)
|
||||||
|
}
|
||||||
|
get("missing", 404)
|
||||||
|
for _, tc := range []struct{ name, content, mime string }{
|
||||||
|
{"png.txt", "\x89PNG\r\n\x1a\n", "image/png"},
|
||||||
|
{"jpeg", "\xff\xd8\xff\xe0", "image/jpeg"},
|
||||||
|
{"gif87", "GIF87a", "image/gif"},
|
||||||
|
{"gif89", "GIF89a", "image/gif"},
|
||||||
|
{"webp", "RIFF\x00\x00\x00\x00WEBPVP8 ", "image/webp"},
|
||||||
|
{"active.svg", `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><style>rect{fill:red}</style><script>alert(1)</script><foreignObject><html xmlns="http://www.w3.org/1999/xhtml"/></foreignObject></svg>`, "image/svg+xml"},
|
||||||
|
{"prefix.SVG", `<?xml version="1.0"?><!--ok--><s:svg xmlns:s="http://www.w3.org/2000/svg"/>`, "image/svg+xml"},
|
||||||
|
{" space ", "GIF89a exact", "image/gif"},
|
||||||
|
{`a\b`, "GIF89a backslash", "image/gif"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
put(tc.name, []byte(tc.content))
|
||||||
|
w := get(tc.name, 200)
|
||||||
|
if w.Body.String() != tc.content {
|
||||||
|
t.Fatal("raw image bytes changed")
|
||||||
|
}
|
||||||
|
disposition, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
|
||||||
|
if err != nil || disposition != "attachment" || params["filename"] != tc.name {
|
||||||
|
t.Fatalf("invalid download filename: %s", w.Header().Get("Content-Disposition"))
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{"Content-Type": tc.mime, "Content-Length": strconv.Itoa(len(tc.content)), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'"} {
|
||||||
|
if got := w.Header().Get(key); got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, content := range []string{"", "plain text", "<html><svg/></html>", `<svg/>`, `<svg xmlns="wrong"/>`, `<svg xmlns="http://www.w3.org/2000/svg">`, `<svg xmlns="http://www.w3.org/2000/svg"/><html/>`, `<svg xmlns="http://www.w3.org/2000/svg"/>junk`, `<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg xmlns="http://www.w3.org/2000/svg">&x;</svg>`, `<svg xmlns="http://www.w3.org/2000/svg">&unknown;</svg>`} {
|
||||||
|
put("bad.svg", []byte(content))
|
||||||
|
get("bad.svg", 415)
|
||||||
|
}
|
||||||
|
put("wrong.png", []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`))
|
||||||
|
get("wrong.png", 415)
|
||||||
|
put("fake.png", []byte("<html>not an image</html>"))
|
||||||
|
get("fake.png", 415)
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "directory"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("directory", 415)
|
||||||
|
if err := os.Symlink("png.txt", filepath.Join(dir, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("link", 404)
|
||||||
|
if err := os.Symlink(dir, filepath.Join(dir, "parent")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("parent/png.txt", 404)
|
||||||
|
if err := os.Link(filepath.Join(dir, "png.txt"), filepath.Join(dir, "hard")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("hard", 404)
|
||||||
|
get("png.txt", 404)
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("fifo", 404)
|
||||||
|
large := bytes.Repeat([]byte{0}, maxImageBytes)
|
||||||
|
copy(large, "GIF89a")
|
||||||
|
put("limit", large)
|
||||||
|
get("limit", 200)
|
||||||
|
put("oversize", append(large, 0))
|
||||||
|
get("oversize", 413)
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='one'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get("gif89", 200)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
||||||
|
vi.mock('react', async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import('react')>(),
|
||||||
|
useState(value: unknown) {
|
||||||
|
const i = state.cursor++;
|
||||||
|
if (!(i in state.slots)) state.slots[i] = value;
|
||||||
|
return [state.slots[i], (next: unknown) => { state.slots[i] = next; }];
|
||||||
|
},
|
||||||
|
useRef(value: unknown) { const i = state.cursor++; return state.slots[i] ??= {current: value}; },
|
||||||
|
useEffect(effect: () => void | (() => void), deps: unknown[]) {
|
||||||
|
const i = state.cursor++;
|
||||||
|
const previous = state.slots[i];
|
||||||
|
if (!previous || deps.some((value, index) => !Object.is(value, previous.deps[index]))) {
|
||||||
|
state.effects.push(() => {
|
||||||
|
previous?.cleanup?.();
|
||||||
|
state.slots[i] = {deps, cleanup: effect()};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./api', async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import('./api')>(),
|
||||||
|
api: {readText: vi.fn(), saveText: vi.fn()},
|
||||||
|
}));
|
||||||
|
import { FileEditor } from './FileEditor';
|
||||||
|
import { api, ApiError } from './api';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { DialogRoot } from './components/ui/dialog';
|
||||||
|
import { maxTextBytes } from './fileEditor';
|
||||||
|
|
||||||
|
const props = {path: 'nested/config.yaml', instanceId: 'instance-two', onClose: vi.fn(), onSaved: vi.fn()};
|
||||||
|
function render() {
|
||||||
|
state.cursor = 0;
|
||||||
|
const tree = FileEditor(props);
|
||||||
|
state.effects.splice(0).forEach(effect => effect());
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
const button = (text: string) => nodes(render(), n => n.type === Button && n.props.children === text)[0];
|
||||||
|
const editor = () => nodes(render(), n => n.props?.label === '文件内容 · ' + props.path)[0];
|
||||||
|
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
async function load() { render(); await flush(); return render(); }
|
||||||
|
function unmount() { state.slots.forEach(slot => slot?.cleanup?.()); state.slots = []; }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.slots = []; state.cursor = 0; state.effects = [];
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.stubGlobal('window', {confirm: vi.fn(() => true), addEventListener: vi.fn(), removeEventListener: vi.fn()});
|
||||||
|
vi.mocked(api.readText).mockResolvedValue({content: 'name: original', version: 'version-one'});
|
||||||
|
vi.mocked(api.saveText).mockResolvedValue({version: 'version-two'});
|
||||||
|
});
|
||||||
|
afterEach(() => { unmount(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
it('loads the selected path and instance, then exposes its content and language', async () => {
|
||||||
|
expect(JSON.stringify(render())).toContain('正在读取文件');
|
||||||
|
expect(button('保存').props.disabled).toBe(true);
|
||||||
|
await flush();
|
||||||
|
expect(api.readText).toHaveBeenCalledExactlyOnceWith(props.path, props.instanceId);
|
||||||
|
expect(editor().props).toMatchObject({value: 'name: original', language: 'yaml', readOnly: false});
|
||||||
|
expect(button('保存').props.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves against the loaded version, path and instance and uses the returned version next time', async () => {
|
||||||
|
await load();
|
||||||
|
editor().props.onChange('name: edited');
|
||||||
|
button('保存').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(api.saveText).toHaveBeenCalledExactlyOnceWith(props.path, props.instanceId, 'name: edited', 'version-one');
|
||||||
|
expect(props.onSaved).toHaveBeenCalledOnce();
|
||||||
|
expect(JSON.stringify(render())).toContain('已保存');
|
||||||
|
expect(button('保存').props.disabled).toBe(true);
|
||||||
|
editor().props.onChange('name: edited again');
|
||||||
|
button('保存').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(api.saveText).toHaveBeenLastCalledWith(props.path, props.instanceId, 'name: edited again', 'version-two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the draft and version after a conflict', async () => {
|
||||||
|
await load();
|
||||||
|
vi.mocked(api.saveText).mockRejectedValueOnce(new ApiError(409, 'conflict'));
|
||||||
|
editor().props.onChange('precious draft');
|
||||||
|
button('保存').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(editor().props.value).toBe('precious draft');
|
||||||
|
expect(JSON.stringify(render())).toContain('当前修改已保留');
|
||||||
|
expect(props.onSaved).not.toHaveBeenCalled();
|
||||||
|
expect(button('保存').props.disabled).toBe(false);
|
||||||
|
button('保存').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(api.saveText).toHaveBeenLastCalledWith(props.path, props.instanceId, 'precious draft', 'version-one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canceling close, dialog dismissal, reload and save retains edits without requests', async () => {
|
||||||
|
await load();
|
||||||
|
editor().props.onChange('keep me');
|
||||||
|
vi.mocked(window.confirm).mockReturnValue(false);
|
||||||
|
button('关闭').props.onClick();
|
||||||
|
nodes(render(), n => n.type === DialogRoot)[0].props.onOpenChange(false);
|
||||||
|
button('重新加载').props.onClick();
|
||||||
|
button('保存').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(editor().props.value).toBe('keep me');
|
||||||
|
expect(props.onClose).not.toHaveBeenCalled();
|
||||||
|
expect(api.readText).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.saveText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers an unload warning only while dirty and cleans it up on reset and unmount', async () => {
|
||||||
|
await load();
|
||||||
|
expect(window.addEventListener).not.toHaveBeenCalled();
|
||||||
|
editor().props.onChange('dirty');
|
||||||
|
render();
|
||||||
|
const listener = vi.mocked(window.addEventListener).mock.calls[0][1] as (event: any) => void;
|
||||||
|
const event = {preventDefault: vi.fn(), returnValue: undefined};
|
||||||
|
listener(event);
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(event.returnValue).toBe('');
|
||||||
|
editor().props.onChange('name: original');
|
||||||
|
render();
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', listener);
|
||||||
|
editor().props.onChange('dirty again');
|
||||||
|
render();
|
||||||
|
const last = vi.mocked(window.addEventListener).mock.calls.at(-1)![1];
|
||||||
|
unmount();
|
||||||
|
expect(window.removeEventListener).toHaveBeenLastCalledWith('beforeunload', last);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[413, '文件超过 1 MiB'], [415, '不是支持的 UTF-8 文本文件']])('shows a useful read error for status %s', async (status, text) => {
|
||||||
|
vi.mocked(api.readText).mockRejectedValueOnce(new ApiError(status as number, 'read failed'));
|
||||||
|
await load();
|
||||||
|
expect(JSON.stringify(render())).toContain(text);
|
||||||
|
expect(editor()).toBeUndefined();
|
||||||
|
expect(button('保存').props.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts UTF-8 bytes and prevents oversize drafts from saving', async () => {
|
||||||
|
await load();
|
||||||
|
editor().props.onChange('界'.repeat(Math.floor(maxTextBytes / 3) + 1));
|
||||||
|
expect(button('保存').props.disabled).toBe(true);
|
||||||
|
expect(JSON.stringify(render())).toContain('内容超过 1 MiB');
|
||||||
|
button('保存').props.onClick();
|
||||||
|
expect(api.saveText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks duplicate saves and close/reload actions while the write is pending', async () => {
|
||||||
|
await load();
|
||||||
|
let resolve!: (result: {version: string}) => void;
|
||||||
|
vi.mocked(api.saveText).mockReturnValueOnce(new Promise(done => { resolve = done; }));
|
||||||
|
editor().props.onChange('pending draft');
|
||||||
|
const save = button('保存').props.onClick;
|
||||||
|
save(); save();
|
||||||
|
expect(api.saveText).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.confirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(editor().props.readOnly).toBe(true);
|
||||||
|
for (const text of ['保存', '关闭', '重新加载']) expect(button(text).props.disabled).toBe(true);
|
||||||
|
button('关闭').props.onClick();
|
||||||
|
button('重新加载').props.onClick();
|
||||||
|
expect(props.onClose).not.toHaveBeenCalled();
|
||||||
|
expect(api.readText).toHaveBeenCalledTimes(1);
|
||||||
|
resolve({version: 'saved-version'});
|
||||||
|
await flush();
|
||||||
|
expect(editor().props.readOnly).toBe(false);
|
||||||
|
expect(props.onSaved).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api, ApiError, type TextFile } from './api';
|
||||||
|
import { Alert, message } from './ui';
|
||||||
|
import { isSensitiveFile } from './fileWarnings';
|
||||||
|
import { fileLanguage, maxTextBytes } from './fileEditor';
|
||||||
|
import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
|
||||||
|
const Editor = lazy(() => import('./MonacoProfileEditor'));
|
||||||
|
|
||||||
|
export function FileEditor({path, instanceId, onClose, onSaved}: {path: string; instanceId: string; onClose: () => void; onSaved: () => void}) {
|
||||||
|
const [original, setOriginal] = useState<TextFile>();
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
const lock = useRef(false);
|
||||||
|
const dirty = !!original && draft !== original.content;
|
||||||
|
const size = new TextEncoder().encode(draft).length;
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true); setError(''); setNotice('');
|
||||||
|
api.readText(path, instanceId).then(file => {
|
||||||
|
if (active) { setOriginal(file); setDraft(file.content); }
|
||||||
|
}).catch(e => {
|
||||||
|
if (active) setError(e instanceof ApiError && e.status === 413 ? '文件超过 1 MiB,请下载后编辑。'
|
||||||
|
: e instanceof ApiError && e.status === 415 ? '不是支持的 UTF-8 文本文件,请下载查看。' : message(e));
|
||||||
|
}).finally(() => { if (active) setLoading(false); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [path, instanceId, reload]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty && !saving) return;
|
||||||
|
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; };
|
||||||
|
window.addEventListener('beforeunload', warn);
|
||||||
|
return () => window.removeEventListener('beforeunload', warn);
|
||||||
|
}, [dirty, saving]);
|
||||||
|
function close() {
|
||||||
|
if (!lock.current && (!dirty || window.confirm('有未保存的修改,确认放弃并关闭?'))) onClose();
|
||||||
|
}
|
||||||
|
function reloadFile() {
|
||||||
|
if (lock.current || (dirty && !window.confirm('重新加载会丢弃当前修改,是否继续?'))) return;
|
||||||
|
setReload(reload + 1);
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!original || !dirty || loading || lock.current || size > maxTextBytes) return;
|
||||||
|
if (!window.confirm('确认保存「' + path + '」?将替换文件内容。文件可能包含配置或密钥,建议先停止实例并备份;这里不执行 YAML 校验,也不强制停机。')) return;
|
||||||
|
lock.current = true; setSaving(true); setError(''); setNotice('');
|
||||||
|
try {
|
||||||
|
const result = await api.saveText(path, instanceId, draft, original.version);
|
||||||
|
setOriginal({content: draft, version: result.version}); setNotice('已保存'); onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError && e.status === 409
|
||||||
|
? '保存被拒绝:文件已变化、被删除,或实例需要恢复。当前修改已保留;请先复制需要的内容,再重新加载核对。'
|
||||||
|
: e instanceof ApiError && e.status === 413 ? '文件超过大小限制或存储配额不足,修改已保留。' : message(e));
|
||||||
|
} finally { lock.current = false; setSaving(false); }
|
||||||
|
}
|
||||||
|
return <DialogRoot open onOpenChange={open => { if (!open) close(); }}>
|
||||||
|
<DialogContent title={'编辑文件 · ' + path} className="w-[min(70rem,calc(100vw-1.5rem))]" aria-busy={saving} onInteractOutside={e => e.preventDefault()}>
|
||||||
|
<p className="text-sm text-muted-foreground">UTF-8 文本,最大 1 MiB。{isSensitiveFile(path) ? '此文件可能包含凭据或敏感配置,请谨慎修改。' : '文件可能包含敏感信息,请勿公开分享。'}建议修改前停机并备份。</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
{loading ? <p role="status">正在读取文件…</p> : original && <Suspense fallback={<p role="status">正在加载编辑器…</p>}>
|
||||||
|
<Editor value={draft} onChange={setDraft} language={fileLanguage(path)} label={'文件内容 · ' + path} readOnly={saving} />
|
||||||
|
</Suspense>}
|
||||||
|
{size > maxTextBytes && <Alert>内容超过 1 MiB,无法保存。</Alert>}
|
||||||
|
<p role="status" className="mt-2 text-sm text-muted-foreground">{saving ? '正在保存…' : dirty ? '有未保存的修改' : notice}</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" disabled={saving || loading} onClick={reloadFile}>重新加载</Button>
|
||||||
|
<Button variant="outline" disabled={saving} onClick={close}>关闭</Button>
|
||||||
|
<Button disabled={saving || loading || !dirty || size > maxTextBytes} onClick={() => void save()}>保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
|
||||||
|
vi.mock('react', async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import('react')>(),
|
||||||
|
useState(value: unknown) {
|
||||||
|
const i = state.cursor++;
|
||||||
|
if (!(i in state.slots)) state.slots[i] = value;
|
||||||
|
return [state.slots[i], (next: unknown) => { state.slots[i] = next; }];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./api', () => ({api: {me: vi.fn()}}));
|
||||||
|
import { ImagePreview } from './ImagePreview';
|
||||||
|
import { api } from './api';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { DialogRoot } from './components/ui/dialog';
|
||||||
|
import { imagePreviewURL } from './imagePreview';
|
||||||
|
import { downloadWarning } from './fileWarnings';
|
||||||
|
|
||||||
|
const props = {path: 'nested/a &b.SVG', instanceId: 'instance two', onClose: vi.fn()};
|
||||||
|
function render() { state.cursor = 0; return ImagePreview(props); }
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
const button = (text: string) => nodes(render(), n => n.type === Button && (n.props.children === text || n.props['aria-label'] === text))[0];
|
||||||
|
const image = () => nodes(render(), n => n.type === 'img')[0];
|
||||||
|
const load = () => image().props.onLoad({currentTarget: {naturalWidth: 800, naturalHeight: 600}});
|
||||||
|
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.slots = []; state.cursor = 0;
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.stubGlobal('window', {confirm: vi.fn(() => true), location: {assign: vi.fn()}});
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('loads the encoded image as an img and shows natural dimensions after loading', () => {
|
||||||
|
expect(image().props).toMatchObject({src: imagePreviewURL(props.path, props.instanceId) + '&revision=0', alt: props.path, referrerPolicy: 'no-referrer'});
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'status')).toHaveLength(1);
|
||||||
|
for (const label of ['原始尺寸', '缩小图片', '放大图片']) expect(button(label).props.disabled).toBe(true);
|
||||||
|
expect(nodes(render(), n => ['iframe', 'object', 'embed'].includes(n.type))).toHaveLength(0);
|
||||||
|
load();
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'status')).toHaveLength(0);
|
||||||
|
expect(JSON.stringify(render())).toContain('800 × 600');
|
||||||
|
expect(button('原始尺寸').props.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zooms within bounds, resets to original size, and returns to fit mode', () => {
|
||||||
|
load();
|
||||||
|
expect(image().props.style).toMatchObject({maxWidth: '100%', maxHeight: '100%', width: 'auto'});
|
||||||
|
button('放大图片').props.onClick();
|
||||||
|
expect(image().props.style.width).toBe(1200);
|
||||||
|
for (let i = 0; i < 10; i++) button('放大图片').props.onClick();
|
||||||
|
expect(image().props.style.width).toBe(3200);
|
||||||
|
expect(button('放大图片').props.disabled).toBe(true);
|
||||||
|
for (let i = 0; i < 20; i++) button('缩小图片').props.onClick();
|
||||||
|
expect(image().props.style.width).toBe(80);
|
||||||
|
expect(button('缩小图片').props.disabled).toBe(true);
|
||||||
|
button('原始尺寸').props.onClick();
|
||||||
|
expect(image().props.style.width).toBe(800);
|
||||||
|
expect(JSON.stringify(render())).toContain('100%');
|
||||||
|
button('适应窗口').props.onClick();
|
||||||
|
expect(image().props.style).toMatchObject({maxWidth: '100%', maxHeight: '100%', width: 'auto'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows load errors and retries only after checking the session, clearing stale dimensions', async () => {
|
||||||
|
load();
|
||||||
|
image().props.onError();
|
||||||
|
expect(image()).toBeUndefined();
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'alert')).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(render())).toContain('16 MiB');
|
||||||
|
expect(button('原始尺寸').props.disabled).toBe(true);
|
||||||
|
let resolve!: () => void;
|
||||||
|
vi.mocked(api.me).mockReturnValueOnce(new Promise(done => { resolve = () => done({} as any); }));
|
||||||
|
button('重试').props.onClick();
|
||||||
|
expect(api.me).toHaveBeenCalledOnce();
|
||||||
|
expect(button('重试').props.disabled).toBe(true);
|
||||||
|
expect(image()).toBeUndefined();
|
||||||
|
resolve();
|
||||||
|
await flush();
|
||||||
|
expect(image().props.src).toBe(imagePreviewURL(props.path, props.instanceId) + '&revision=1');
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'status')).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(render())).not.toContain('800 × 600');
|
||||||
|
load();
|
||||||
|
expect(button('原始尺寸').props.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the error visible when session recovery fails and permits another retry', async () => {
|
||||||
|
image().props.onError();
|
||||||
|
vi.mocked(api.me).mockRejectedValueOnce(new Error('expired'));
|
||||||
|
button('重试').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(image()).toBeUndefined();
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'alert')).toHaveLength(1);
|
||||||
|
expect(button('重试').props.disabled).toBe(false);
|
||||||
|
button('重试').props.onClick();
|
||||||
|
await flush();
|
||||||
|
expect(api.me).toHaveBeenCalledTimes(2);
|
||||||
|
expect(image()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires download confirmation and preserves the selected path and instance', () => {
|
||||||
|
vi.mocked(window.confirm).mockReturnValueOnce(false);
|
||||||
|
button('下载图片').props.onClick();
|
||||||
|
expect(window.confirm).toHaveBeenCalledExactlyOnceWith(downloadWarning(props.path, false));
|
||||||
|
expect(window.location.assign).not.toHaveBeenCalled();
|
||||||
|
button('下载图片').props.onClick();
|
||||||
|
expect(window.location.assign).toHaveBeenCalledExactlyOnceWith(imagePreviewURL(props.path, props.instanceId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes from the footer and dialog dismissal but not an open notification', () => {
|
||||||
|
nodes(render(), n => n.type === DialogRoot)[0].props.onOpenChange(true);
|
||||||
|
expect(props.onClose).not.toHaveBeenCalled();
|
||||||
|
button('关闭').props.onClick();
|
||||||
|
nodes(render(), n => n.type === DialogRoot)[0].props.onOpenChange(false);
|
||||||
|
expect(props.onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { imagePreviewURL } from './imagePreview';
|
||||||
|
import { api } from './api';
|
||||||
|
import { downloadWarning } from './fileWarnings';
|
||||||
|
|
||||||
|
export function ImagePreview({path, instanceId, onClose}: {path: string; instanceId: string; onClose: () => void}) {
|
||||||
|
const [scale, setScale] = useState<number | null>(null);
|
||||||
|
const [size, setSize] = useState<{width: number; height: number}>();
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
async function retry() {
|
||||||
|
setRetrying(true);
|
||||||
|
try { await api.me(); setError(false); setSize(undefined); setRevision(revision + 1); }
|
||||||
|
catch { setError(true); }
|
||||||
|
finally { setRetrying(false); }
|
||||||
|
}
|
||||||
|
function download() {
|
||||||
|
if (window.confirm(downloadWarning(path, false))) window.location.assign(imagePreviewURL(path, instanceId));
|
||||||
|
}
|
||||||
|
return <DialogRoot open onOpenChange={open => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent title={'图片预览 · ' + path} className="w-[min(70rem,calc(100vw-1.5rem))]">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setScale(null)}>适应窗口</Button>
|
||||||
|
<Button variant="outline" disabled={!size || error} onClick={() => setScale(1)}>原始尺寸</Button>
|
||||||
|
<Button variant="outline" aria-label="缩小图片" disabled={!size || error || (scale !== null && scale <= 0.1)} onClick={() => setScale(Math.max(0.1, (scale ?? 1) / 1.5))}>−</Button>
|
||||||
|
<Button variant="outline" aria-label="放大图片" disabled={!size || error || (scale !== null && scale >= 4)} onClick={() => setScale(Math.min(4, (scale ?? 1) * 1.5))}>+</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">{scale === null ? '适应窗口' : Math.round(scale * 100) + '%'}{size && ' · ' + size.width + ' × ' + size.height}</span>
|
||||||
|
</div>
|
||||||
|
{error ? <div role="alert" className="rounded-lg bg-destructive-muted p-4 text-destructive">图片无法预览:可能已删除、格式损坏、超过 16 MiB 或登录已失效。<Button variant="outline" className="ml-2" disabled={retrying} onClick={() => void retry()}>重试</Button></div>
|
||||||
|
: <div className="h-[55dvh] overflow-auto rounded-lg border border-border bg-muted p-2">
|
||||||
|
{!size && <p role="status">正在加载图片…</p>}
|
||||||
|
<img key={revision} src={imagePreviewURL(path, instanceId) + '&revision=' + revision} alt={path} referrerPolicy="no-referrer"
|
||||||
|
className="block" style={scale === null ? {maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto', margin: 'auto'} : {maxWidth: 'none', width: size ? size.width * scale : undefined, height: 'auto'}}
|
||||||
|
onLoad={e => setSize({width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight})} onError={() => setError(true)} />
|
||||||
|
</div>}
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">支持 PNG、JPEG、GIF、WebP、SVG,最大 16 MiB。SVG 仅以图片展示,不执行脚本。图片也可能包含敏感信息,请勿公开分享。</p>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={download}>下载图片</Button><Button onClick={onClose}>关闭</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>;
|
||||||
|
}
|
||||||
@@ -2,20 +2,39 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||||
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
|
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/css/css.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/go/go.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/html/html.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/java/java.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/python/python.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/rust/rust.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/scss/scss.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution';
|
||||||
|
import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution';
|
||||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||||
|
import JSONWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
import { useNightMode } from './nightMode';
|
import { useNightMode } from './nightMode';
|
||||||
|
|
||||||
// Vite emits this worker locally; no AMD loader or CDN is involved.
|
// Vite emits this worker locally; no AMD loader or CDN is involved.
|
||||||
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
|
self.MonacoEnvironment = { getWorker: (_id, label) => label === 'json' ? new JSONWorker() : new EditorWorker() };
|
||||||
|
|
||||||
export interface ProfileEditorProps {
|
export interface ProfileEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonacoProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置' }: ProfileEditorProps) {
|
export default function MonacoProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置', language = 'yaml' }: ProfileEditorProps) {
|
||||||
const host = useRef<HTMLDivElement>(null);
|
const host = useRef<HTMLDivElement>(null);
|
||||||
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
const change = useRef(onChange);
|
const change = useRef(onChange);
|
||||||
@@ -25,7 +44,7 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
const dark = useNightMode();
|
const dark = useNightMode();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host.current) return;
|
if (!host.current) return;
|
||||||
const model = monaco.editor.createModel(initialValue.current, 'yaml');
|
const model = monaco.editor.createModel(initialValue.current, language);
|
||||||
const instance = monaco.editor.create(host.current, {
|
const instance = monaco.editor.create(host.current, {
|
||||||
model, automaticLayout: true, minimap: { enabled: false },
|
model, automaticLayout: true, minimap: { enabled: false },
|
||||||
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
|
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
|
||||||
@@ -34,13 +53,13 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
});
|
});
|
||||||
editor.current = instance;
|
editor.current = instance;
|
||||||
const listener = instance.onDidChangeModelContent(() => {
|
const listener = instance.onDidChangeModelContent(() => {
|
||||||
if (!updating.current) change.current?.(instance.getValue());
|
if (!updating.current) change.current?.(model.getValue(undefined, true));
|
||||||
});
|
});
|
||||||
return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; };
|
return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; };
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const instance = editor.current;
|
const instance = editor.current;
|
||||||
if (instance && instance.getValue() !== value) {
|
if (instance && instance.getModel()?.getValue(undefined, true) !== value) {
|
||||||
updating.current = true;
|
updating.current = true;
|
||||||
instance.setValue(value);
|
instance.setValue(value);
|
||||||
updating.current = false;
|
updating.current = false;
|
||||||
@@ -48,5 +67,6 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
|
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
|
||||||
useEffect(() => { monaco.editor.setTheme(dark ? 'vs-dark' : 'vs'); }, [dark]);
|
useEffect(() => { monaco.editor.setTheme(dark ? 'vs-dark' : 'vs'); }, [dark]);
|
||||||
|
useEffect(() => { const model = editor.current?.getModel(); if (model) monaco.editor.setModelLanguage(model, language); }, [language]);
|
||||||
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--color-border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
|
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--color-border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ vi.mock('./ui', async importOriginal => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./api', () => ({api: {download: vi.fn(() => '/download-test'), upload: vi.fn(async () => {}), rename: vi.fn(async () => {}), delete: vi.fn(async () => {})}}));
|
vi.mock('./api', () => ({api: {download: vi.fn(() => '/download-test'), upload: vi.fn(async () => {}), rename: vi.fn(async () => {}), delete: vi.fn(async () => {})}}));
|
||||||
import { Workspace } from './Workspace';
|
import { Workspace } from './Workspace';
|
||||||
|
import { FileEditor } from './FileEditor';
|
||||||
|
import { ImagePreview } from './ImagePreview';
|
||||||
|
import { maxImageBytes } from './imagePreview';
|
||||||
|
import { maxTextBytes } from './fileEditor';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { DialogContent } from './components/ui/dialog';
|
import { DialogContent } from './components/ui/dialog';
|
||||||
@@ -34,12 +38,72 @@ const dialog = () => nodes(render(), n => n.type === ConfirmDialog)[0];
|
|||||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||||
const locationAssign = () => vi.mocked((window as any).location.assign);
|
const locationAssign = () => vi.mocked((window as any).location.assign);
|
||||||
|
|
||||||
|
it('opens image previews from desktop and mobile actions with path and instance identity', () => {
|
||||||
|
state.entries = [{name: 'photo.PNG', is_dir: false, size: maxImageBytes}];
|
||||||
|
const buttons = nodes(render(), byText('预览'));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button.props.disabled).toBe(false);
|
||||||
|
button.props.onClick();
|
||||||
|
const preview = nodes(render(), n => n.type === ImagePreview)[0];
|
||||||
|
expect(preview.props).toMatchObject({path: 'photo.PNG', instanceId: 'one'});
|
||||||
|
expect(preview.key).toBe('one:photo.PNG');
|
||||||
|
preview.props.onClose();
|
||||||
|
expect(nodes(render(), n => n.type === ImagePreview)).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers preview only for image files, never directories or unsupported extensions', () => {
|
||||||
|
state.entries = [
|
||||||
|
{name: 'directory.png', is_dir: true, size: 0},
|
||||||
|
{name: 'notes.txt', is_dir: false, size: 10},
|
||||||
|
{name: 'photo.bmp', is_dir: false, size: 10},
|
||||||
|
{name: 'photo.svg.html', is_dir: false, size: 10},
|
||||||
|
];
|
||||||
|
expect(nodes(render(), byText('预览'))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables both preview entry points above the 16 MiB boundary', () => {
|
||||||
|
state.entries = [{name: 'large.webp', is_dir: false, size: maxImageBytes + 1}];
|
||||||
|
const buttons = nodes(render(), byText('预览'));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button.props.disabled).toBe(true);
|
||||||
|
expect(button.props.title).toContain('超过 16 MiB');
|
||||||
|
}
|
||||||
|
expect(nodes(render(), n => n.type === ImagePreview)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.slots = []; state.cursor = 0; state.entries = [{name: '.credentials.yaml', is_dir: false}];
|
state.slots = []; state.cursor = 0; state.entries = [{name: '.credentials.yaml', is_dir: false}];
|
||||||
vi.clearAllMocks(); vi.stubGlobal('window', {location: {assign: vi.fn()}});
|
vi.clearAllMocks(); vi.stubGlobal('window', {location: {assign: vi.fn()}});
|
||||||
});
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('opens a path- and instance-keyed editor from both desktop and mobile file actions', () => {
|
||||||
|
const buttons = nodes(render(), byText('编辑'));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button.props.disabled).toBe(false);
|
||||||
|
button.props.onClick();
|
||||||
|
const editor = nodes(render(), n => n.type === FileEditor)[0];
|
||||||
|
expect(editor.props).toMatchObject({path: '.credentials.yaml', instanceId: 'one'});
|
||||||
|
expect(editor.key).toBe('one:.credentials.yaml');
|
||||||
|
editor.props.onClose();
|
||||||
|
expect(nodes(render(), n => n.type === FileEditor)).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer directory editing and disables oversized file editing', () => {
|
||||||
|
state.entries = [{name: 'directory', is_dir: true}, {name: 'large.txt', is_dir: false, size: maxTextBytes + 1}];
|
||||||
|
const buttons = nodes(render(), byText('编辑'));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button.props.disabled).toBe(true);
|
||||||
|
expect(button.props.title).toContain('超过 1 MiB');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each([false, true])('cancel blocks download navigation and acceptance preserves owner access (directory=%s)', async is_dir => {
|
it.each([false, true])('cancel blocks download navigation and acceptance preserves owner access (directory=%s)', async is_dir => {
|
||||||
state.entries = [{name: is_dir ? 'photos' : 'renamed.txt', is_dir}];
|
state.entries = [{name: is_dir ? 'photos' : 'renamed.txt', is_dir}];
|
||||||
// Both the desktop table and the mobile cards render a download button.
|
// Both the desktop table and the mobile cards render a download button.
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { buttonVariants } from './components/ui/button';
|
|||||||
import { inputClass } from './components/ui/input';
|
import { inputClass } from './components/ui/input';
|
||||||
import { AlertBox } from './components/ui/alert';
|
import { AlertBox } from './components/ui/alert';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
import { FileEditor } from './FileEditor';
|
||||||
|
import { maxTextBytes } from './fileEditor';
|
||||||
|
import { ImagePreview } from './ImagePreview';
|
||||||
|
import { isPreviewImage, maxImageBytes } from './imagePreview';
|
||||||
|
|
||||||
export function Workspace({runtime}: {runtime: ReturnType<typeof useRuntimeInstances>}) {
|
export function Workspace({runtime}: {runtime: ReturnType<typeof useRuntimeInstances>}) {
|
||||||
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
|
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
|
||||||
@@ -45,6 +49,8 @@ function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: Fil
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const go = (target: string) => setPath(navigateDirectory(path, target, action.busy));
|
const go = (target: string) => setPath(navigateDirectory(path, target, action.busy));
|
||||||
const [newName, setNewName] = useState(''), [rename, setRename] = useState<{path: string; name: string}>();
|
const [newName, setNewName] = useState(''), [rename, setRename] = useState<{path: string; name: string}>();
|
||||||
|
const [editing, setEditing] = useState<string>();
|
||||||
|
const [preview, setPreview] = useState<string>();
|
||||||
const join = (name: string) => joinFilePath(path, name);
|
const join = (name: string) => joinFilePath(path, name);
|
||||||
const refresh = async () => { await Promise.all([tree.refresh(), usage.refresh()]); };
|
const refresh = async () => { await Promise.all([tree.refresh(), usage.refresh()]); };
|
||||||
function mkdir(e: FormEvent) { e.preventDefault(); void action.run(async () => { await api.mkdir(join(newName), instanceId); setNewName(''); await refresh(); }, '目录已创建'); }
|
function mkdir(e: FormEvent) { e.preventDefault(); void action.run(async () => { await api.mkdir(join(newName), instanceId); setNewName(''); await refresh(); }, '目录已创建'); }
|
||||||
@@ -67,6 +73,8 @@ function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: Fil
|
|||||||
const actionButtons = (entry: Entry) => {
|
const actionButtons = (entry: Entry) => {
|
||||||
const target = entry.path || join(entry.name);
|
const target = entry.path || join(entry.name);
|
||||||
return <>
|
return <>
|
||||||
|
{!entry.is_dir && isPreviewImage(entry.name) && <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || entry.size > maxImageBytes} title={entry.size > maxImageBytes ? '超过 16 MiB,请下载查看' : '预览图片'} onClick={() => setPreview(target)}>预览</button>}
|
||||||
|
{!entry.is_dir && <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || entry.size > maxTextBytes} title={entry.size > maxTextBytes ? '超过 1 MiB,请下载后编辑' : '查看和编辑文本文件'} onClick={() => setEditing(target)}><Pencil aria-hidden />编辑</button>}
|
||||||
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} title={downloadWarning(target, entry.is_dir)} onClick={() => void downloadEntry(target, entry.is_dir)}><Download aria-hidden />下载</button>
|
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} title={downloadWarning(target, entry.is_dir)} onClick={() => void downloadEntry(target, entry.is_dir)}><Download aria-hidden />下载</button>
|
||||||
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}))} disabled={action.busy} onClick={() => setRename({path: target, name: entry.name})}><Pencil aria-hidden />重命名</button>
|
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}))} disabled={action.busy} onClick={() => setRename({path: target, name: entry.name})}><Pencil aria-hidden />重命名</button>
|
||||||
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}), 'text-destructive hover:bg-destructive-muted')} disabled={action.busy} onClick={() => void deleteEntry(target)}><Trash2 aria-hidden />删除</button>
|
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}), 'text-destructive hover:bg-destructive-muted')} disabled={action.busy} onClick={() => void deleteEntry(target)}><Trash2 aria-hidden />删除</button>
|
||||||
@@ -75,6 +83,8 @@ function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: Fil
|
|||||||
|
|
||||||
return <>
|
return <>
|
||||||
{controls(action.busy)}
|
{controls(action.busy)}
|
||||||
|
{preview && <ImagePreview key={instanceId + ':' + preview} path={preview} instanceId={instanceId} onClose={() => setPreview(undefined)} />}
|
||||||
|
{editing && <FileEditor key={instanceId + ':' + editing} path={editing} instanceId={instanceId} onClose={() => setEditing(undefined)} onSaved={() => { void refresh(); }} />}
|
||||||
{confirm.element}
|
{confirm.element}
|
||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-secondary/50 p-4 sm:p-5">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-secondary/50 p-4 sm:p-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Release { release_id: string; version: string; commit: string;
|
|||||||
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
|
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
|
||||||
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
|
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
|
||||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||||
|
export interface TextFile { content: string; version: string }
|
||||||
export interface Backup { id: string; created_at: string; size: number }
|
export interface Backup { id: string; created_at: string; size: number }
|
||||||
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
||||||
export function createApi(fetcher: typeof fetch = fetch) {
|
export function createApi(fetcher: typeof fetch = fetch) {
|
||||||
@@ -88,6 +89,8 @@ export function createApi(fetcher: typeof fetch = fetch) {
|
|||||||
build: (id: string) => request<Build>('/builds/' + encodeURIComponent(id)),
|
build: (id: string) => request<Build>('/builds/' + encodeURIComponent(id)),
|
||||||
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
|
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
|
||||||
tree: (path: string, instance?: string) => request<Entry[]>('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')),
|
tree: (path: string, instance?: string) => request<Entry[]>('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')),
|
||||||
|
readText: (path: string, instance: string) => request<TextFile>('/ws/text?path=' + encodeURIComponent(path) + '&instance=' + encodeURIComponent(instance)),
|
||||||
|
saveText: (path: string, instance: string, content: string, version: string) => request<{version: string}>('/ws/text?path=' + encodeURIComponent(path) + '&instance=' + encodeURIComponent(instance), 'PUT', {content, version}),
|
||||||
download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''),
|
download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''),
|
||||||
mkdir: (path: string, instance?: string) => request('/ws/mkdir' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path}),
|
mkdir: (path: string, instance?: string) => request('/ws/mkdir' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path}),
|
||||||
rename: (path: string, newPath: string, instance?: string) => request('/ws/rename' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path, new_path: newPath}),
|
rename: (path: string, newPath: string, instance?: string) => request('/ws/rename' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path, new_path: newPath}),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { fileLanguage, maxTextBytes } from './fileEditor';
|
||||||
|
|
||||||
|
it('sets the text limit to exactly one MiB', () => {
|
||||||
|
expect(maxTextBytes).toBe(1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['nested/CONFIG.YAML', 'yaml'], ['config.yml', 'yaml'],
|
||||||
|
['settings.jsonc', 'json'], ['app.tsx', 'typescript'], ['app.mjs', 'javascript'],
|
||||||
|
['README.md', 'markdown'], ['script.sh', 'shell'], ['main.py', 'python'],
|
||||||
|
['main.go', 'go'], ['main.rs', 'rust'], ['index.html', 'html'],
|
||||||
|
['theme.css', 'css'], ['theme.scss', 'scss'], ['data.xml', 'xml'],
|
||||||
|
['schema.sql', 'sql'], ['settings.toml', 'ini'], ['service.conf', 'ini'],
|
||||||
|
['Main.java', 'java'], ['main.c', 'c'], ['include.h', 'c'], ['main.cpp', 'cpp'],
|
||||||
|
['build/Dockerfile', 'dockerfile'], ['secrets/.env', 'ini'], ['secrets/.env.local', 'ini'],
|
||||||
|
['README', 'plaintext'], ['data.unknown', 'plaintext'], ['folder.yaml/plain', 'plaintext'],
|
||||||
|
])('selects a language for %s', (path, language) => {
|
||||||
|
expect(fileLanguage(path)).toBe(language);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const maxTextBytes = 1 << 20;
|
||||||
|
|
||||||
|
export function fileLanguage(path: string): string {
|
||||||
|
const name = path.split('/').at(-1)!.toLowerCase();
|
||||||
|
if (name === 'dockerfile') return 'dockerfile';
|
||||||
|
if (name === '.env' || name.startsWith('.env.')) return 'ini';
|
||||||
|
const languages: Record<string, string> = {
|
||||||
|
yaml: 'yaml', yml: 'yaml', json: 'json', jsonc: 'json',
|
||||||
|
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||||
|
ts: 'typescript', tsx: 'typescript', md: 'markdown', markdown: 'markdown',
|
||||||
|
sh: 'shell', bash: 'shell', py: 'python', go: 'go', rs: 'rust',
|
||||||
|
html: 'html', css: 'css', scss: 'scss', xml: 'xml', sql: 'sql',
|
||||||
|
ini: 'ini', toml: 'ini', conf: 'ini', java: 'java', c: 'c', h: 'c', cpp: 'cpp',
|
||||||
|
};
|
||||||
|
return languages[name.split('.').at(-1)!] || 'plaintext';
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const homeAccessNotice = '所有者可完整管理实例 home,包括读取、下载凭据;不承诺凭据仅写入(write-only)。专用 profile 页面提供受控编辑流程,不是强制安全边界;通用文件上传、重命名或删除可绕过该流程的 YAML 校验和停机要求。';
|
export const homeAccessNotice = '所有者可完整管理实例 home,包括读取、下载凭据;不承诺凭据仅写入(write-only)。专用 profile 页面提供受控编辑流程,不是强制安全边界;通用文件编辑、上传、重命名或删除可绕过该流程的 YAML 校验和停机要求。';
|
||||||
const configMutationWarning = '修改配置可能影响实例运行。建议先停止实例并备份,再继续;这里不强制停机。';
|
const configMutationWarning = '修改配置可能影响实例运行。建议先停止实例并备份,再继续;这里不强制停机。';
|
||||||
|
|
||||||
// Filename hints are UX only: renamed secrets cannot be reliably identified.
|
// Filename hints are UX only: renamed secrets cannot be reliably identified.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { imagePreviewURL, isPreviewImage, maxImageBytes } from './imagePreview';
|
||||||
|
|
||||||
|
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'])('allows .%s images regardless of case', extension => {
|
||||||
|
expect(isPreviewImage('nested/photo.' + extension)).toBe(true);
|
||||||
|
expect(isPreviewImage('nested/photo.' + extension.toUpperCase())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['photo.bmp', 'photo.ico', 'photo.avif', 'photo.png.txt', 'photo.svg.html', 'notpng', 'photo-jpeg', 'png', ''])('rejects unsupported or missing extensions: %s', path => {
|
||||||
|
expect(isPreviewImage(path)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes the entire path and instance independently without query injection', () => {
|
||||||
|
expect(imagePreviewURL('相册/a &b#?%.PNG', 'two /&?#%')).toBe('/api/ws/image?path=%E7%9B%B8%E5%86%8C%2Fa%20%26b%23%3F%25.PNG&instance=two%20%2F%26%3F%23%25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits preview size to 16 MiB', () => {
|
||||||
|
expect(maxImageBytes).toBe(16 * 1024 * 1024);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const maxImageBytes = 16 << 20;
|
||||||
|
export const isPreviewImage = (path: string) => /\.(png|jpe?g|gif|webp|svg)$/i.test(path);
|
||||||
|
export const imagePreviewURL = (path: string, instance: string) => '/api/ws/image?path=' + encodeURIComponent(path) + '&instance=' + encodeURIComponent(instance);
|
||||||
Reference in New Issue
Block a user