feat(workspace): add text editing and image previews
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user