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
+178 -1
View File
@@ -3,8 +3,10 @@ package api
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"mime"
@@ -15,6 +17,8 @@ import (
"strings"
"syscall"
"time"
"unicode"
"unicode/utf8"
"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) {
if r.URL.Path != "/api/ws/download" {
if r.URL.Path != "/api/ws/download" && r.URL.Path != "/api/ws/image" {
var flush func()
w, flush = deferJSONResponse(w)
defer flush()
@@ -190,11 +194,184 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
a.wsUpload(w, r, root, u)
case "download":
a.wsDownload(w, r, root)
case "image":
a.wsImage(w, r, root)
case "text":
a.wsText(w, r, root, u)
default:
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) {
if err := rootSafe(root, rel, false); err != nil {
return nil, err