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