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
+117
View File
@@ -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 ""
}
}
}