feat(workspace): add text editing and image previews

This commit is contained in:
2026-10-15 23:10:13 +08:00
parent d380e18db0
commit a8ba3d74dc
19 changed files with 1188 additions and 7 deletions
+183
View File
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
@@ -20,6 +21,188 @@ import (
"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) {
for _, tc := range []struct {
name, policy string