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
+129
View File
@@ -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)
}