Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
989 lines
24 KiB
Go
989 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const maxUpload = 32 << 20
|
|
|
|
var uploadSlots = make(chan struct{}, 4)
|
|
|
|
const (
|
|
maxZipEntries = 10000
|
|
maxZipDepth = 64
|
|
maxZipBytes int64 = 10 << 30
|
|
zipTimeout = 2 * time.Minute
|
|
)
|
|
|
|
var zipSlots = make(chan struct{}, 4)
|
|
var errZipLimit = errors.New("archive limit exceeded")
|
|
|
|
func renameNoReplace(root *os.Root, old, next string) error {
|
|
anchor, err := root.Open(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer anchor.Close()
|
|
openParent := func(p string) (int, error) {
|
|
return 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,
|
|
})
|
|
}
|
|
src, err := openParent(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unix.Close(src)
|
|
dst, err := openParent(next)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unix.Close(dst)
|
|
var st unix.Stat_t
|
|
if err = unix.Fstatat(src, path.Base(old), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
return err
|
|
}
|
|
if st.Mode&unix.S_IFMT == unix.S_IFLNK {
|
|
return errors.New("unsafe path")
|
|
}
|
|
return unix.Renameat2(src, path.Base(old), dst, path.Base(next), unix.RENAME_NOREPLACE)
|
|
}
|
|
|
|
func safeRel(p string) (string, error) {
|
|
p = strings.TrimSpace(strings.ReplaceAll(p, `\`, `/`))
|
|
if p == "" {
|
|
return ".", nil
|
|
}
|
|
if strings.HasPrefix(p, "/") {
|
|
return "", errors.New("invalid path")
|
|
}
|
|
c := path.Clean(p)
|
|
if c == ".." || strings.HasPrefix(c, "../") {
|
|
return "", errors.New("invalid path")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (a *API) openWorkspace(u store.User, instanceID string) (*os.Root, error) {
|
|
if instanceID == "" {
|
|
return nil, errors.New("instance required")
|
|
}
|
|
base, err := os.OpenRoot(a.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer base.Close()
|
|
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
|
|
if err := rootSafe(base, rel, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return base.OpenRoot(rel)
|
|
}
|
|
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
for i := range parts {
|
|
q := strings.Join(parts[:i+1], "/")
|
|
st, err := root.Lstat(q)
|
|
if err != nil {
|
|
if finalMayMissing && i == len(parts)-1 && errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if st.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !st.IsDir()) {
|
|
return errors.New("unsafe path")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/ws/download" && r.URL.Path != "/api/ws/image" {
|
|
var flush func()
|
|
w, flush = deferJSONResponse(w)
|
|
defer flush()
|
|
}
|
|
u, _, err := a.user(r)
|
|
if err != nil {
|
|
fail(w, 401, "authentication required")
|
|
return
|
|
}
|
|
instanceID := strings.TrimSpace(r.URL.Query().Get("instance"))
|
|
if instanceID == "" && r.URL.Path != "/api/ws/usage" {
|
|
fail(w, http.StatusGone, "shared files removed; instance required")
|
|
return
|
|
}
|
|
if instanceID != "" {
|
|
// Pin this instance's home without holding the instance lifecycle lock
|
|
// across client reads/writes. Lifecycle mutations reject active leases.
|
|
inst := a.instanceLocks.Acquire(instanceID)
|
|
i, e := a.s.GetInstance(instanceID)
|
|
if e != nil || i.UID != u.ID {
|
|
inst.Release()
|
|
fail(w, 404, "instance not found")
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) {
|
|
inst.Release()
|
|
fail(w, 409, "instance requires recovery")
|
|
return
|
|
}
|
|
a.readersMu.Lock()
|
|
if a.workspaceReaders == nil {
|
|
a.workspaceReaders = make(map[string]int)
|
|
}
|
|
a.workspaceReaders[instanceID]++
|
|
a.readersMu.Unlock()
|
|
inst.Release()
|
|
defer func() {
|
|
a.readersMu.Lock()
|
|
if a.workspaceReaders != nil {
|
|
a.workspaceReaders[instanceID]--
|
|
if a.workspaceReaders[instanceID] == 0 {
|
|
delete(a.workspaceReaders, instanceID)
|
|
}
|
|
}
|
|
a.readersMu.Unlock()
|
|
}()
|
|
}
|
|
if r.URL.Path == "/api/ws/usage" {
|
|
a.wsUsage(w, r, u)
|
|
return
|
|
}
|
|
user := a.userLocks.Acquire(u.ID)
|
|
root, err := a.openWorkspace(u, instanceID)
|
|
user.Release()
|
|
if err != nil {
|
|
fail(w, 500, "storage unavailable")
|
|
return
|
|
}
|
|
defer root.Close()
|
|
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
|
|
case "tree":
|
|
a.wsTree(w, r, root, u)
|
|
case "mkdir":
|
|
a.wsMkdir(w, r, root, u)
|
|
case "rename":
|
|
a.wsRename(w, r, root, u)
|
|
case "delete":
|
|
a.wsDelete(w, r, root, u)
|
|
case "upload":
|
|
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
|
|
}
|
|
return root.Lstat(rel)
|
|
}
|
|
|
|
func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, u store.User) {
|
|
if r.Method != "GET" {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
user := a.userLocks.Acquire(u.ID)
|
|
n, e := a.userUsage(u)
|
|
user.Release()
|
|
if e != nil {
|
|
fail(w, 500, "usage unavailable")
|
|
return
|
|
}
|
|
writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota})
|
|
}
|
|
|
|
func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
|
user := a.userLocks.Acquire(u.ID)
|
|
locked := user
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
if r.Method != http.MethodGet {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
rel, err := safeRel(r.URL.Query().Get("path"))
|
|
if err != nil {
|
|
fail(w, 400, "invalid path")
|
|
return
|
|
}
|
|
st, err := entry(root, rel)
|
|
if err != nil || !st.IsDir() {
|
|
fail(w, 404, "not found")
|
|
return
|
|
}
|
|
es, err := readRootDir(root, rel)
|
|
if err != nil {
|
|
if errors.Is(err, errDirectoryLimit) {
|
|
fail(w, 413, "directory entry limit exceeded")
|
|
return
|
|
}
|
|
fail(w, 404, "not found")
|
|
return
|
|
}
|
|
out := make([]map[string]any, 0, len(es))
|
|
for _, de := range es {
|
|
p := de.Name()
|
|
if rel != "." {
|
|
p = rel + "/" + p
|
|
}
|
|
st, err := entry(root, p)
|
|
if err != nil || (!st.IsDir() && !st.Mode().IsRegular()) {
|
|
continue
|
|
}
|
|
sz := int64(0)
|
|
if !st.IsDir() {
|
|
sz = st.Size()
|
|
}
|
|
out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
|
|
locked.Release()
|
|
locked = nil
|
|
writeJSON(w, out)
|
|
}
|
|
|
|
func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
|
if r.Method != http.MethodPost {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
var x struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if json.NewDecoder(r.Body).Decode(&x) != nil {
|
|
fail(w, 400, "invalid json")
|
|
return
|
|
}
|
|
p, e := safeRel(x.Path)
|
|
user := a.userLocks.Acquire(u.ID)
|
|
locked := user
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil {
|
|
fail(w, 400, "cannot create directory")
|
|
return
|
|
}
|
|
if e = root.Mkdir(p, 0750); e != nil {
|
|
fail(w, 400, "cannot create directory")
|
|
return
|
|
}
|
|
if f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0); e == nil {
|
|
_ = f.Chown(1000, 1000)
|
|
f.Close()
|
|
}
|
|
locked.Release()
|
|
locked = nil
|
|
a.s.AddAudit(u.ID, "workspace.mkdir", p, "")
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
|
if r.Method != http.MethodPost {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
var x struct {
|
|
Path string `json:"path"`
|
|
NewPath string `json:"new_path"`
|
|
}
|
|
if json.NewDecoder(r.Body).Decode(&x) != nil {
|
|
fail(w, 400, "invalid json")
|
|
return
|
|
}
|
|
p, e := safeRel(x.Path)
|
|
q, e2 := safeRel(x.NewPath)
|
|
user := a.userLocks.Acquire(u.ID)
|
|
locked := user
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil {
|
|
fail(w, 400, "rename failed")
|
|
return
|
|
}
|
|
st, e := root.Lstat(p)
|
|
if e != nil || st.Mode()&os.ModeSymlink != 0 {
|
|
fail(w, 400, "rename failed")
|
|
return
|
|
}
|
|
if _, e = root.Lstat(q); e == nil {
|
|
fail(w, 400, "rename failed")
|
|
return
|
|
}
|
|
if e = renameNoReplace(root, p, q); e != nil {
|
|
fail(w, 400, "rename failed")
|
|
return
|
|
}
|
|
locked.Release()
|
|
locked = nil
|
|
a.s.AddAudit(u.ID, "workspace.rename", p, q)
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
|
user := a.userLocks.Acquire(u.ID)
|
|
locked := user
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
if r.Method != http.MethodDelete {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
p, e := safeRel(r.URL.Query().Get("path"))
|
|
if e != nil || p == "." {
|
|
fail(w, 400, "delete failed")
|
|
return
|
|
}
|
|
st, e := entry(root, p)
|
|
if e != nil || st.Mode()&os.ModeSymlink != 0 {
|
|
fail(w, 400, "delete failed")
|
|
return
|
|
}
|
|
if st.IsDir() {
|
|
es, e := readRootDir(root, p)
|
|
if e != nil || len(es) != 0 {
|
|
fail(w, 400, "delete failed")
|
|
return
|
|
}
|
|
}
|
|
if e = root.Remove(p); e != nil {
|
|
fail(w, 400, "delete failed")
|
|
return
|
|
}
|
|
locked.Release()
|
|
locked = nil
|
|
a.s.AddAudit(u.ID, "workspace.delete", p, "")
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
|
if r.Method != "POST" {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
select {
|
|
case uploadSlots <- struct{}{}:
|
|
defer func() { <-uploadSlots }()
|
|
default:
|
|
fail(w, 429, "too many concurrent uploads")
|
|
return
|
|
}
|
|
usedBefore, usageErr := a.userUsage(u)
|
|
if usageErr != nil || usedBefore >= u.Quota {
|
|
fail(w, 413, "quota exceeded or usage unavailable")
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUpload+(1<<20))
|
|
controller := http.NewResponseController(w)
|
|
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
fail(w, 400, "upload unavailable")
|
|
return
|
|
}
|
|
defer controller.SetReadDeadline(time.Time{})
|
|
if r.ParseMultipartForm(1<<20) != nil {
|
|
fail(w, 400, "invalid upload")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
user := a.userLocks.Acquire(u.ID)
|
|
locked := user
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
dir, e := safeRel(r.FormValue("path"))
|
|
if e != nil || rootSafe(root, dir, false) != nil {
|
|
fail(w, 400, "invalid directory")
|
|
return
|
|
}
|
|
input, h, e := r.FormFile("file")
|
|
if e != nil {
|
|
fail(w, 400, "file required")
|
|
return
|
|
}
|
|
defer input.Close()
|
|
name, e := safeRel(h.Filename)
|
|
if e != nil || name == "." || path.Base(name) != name {
|
|
fail(w, 400, "invalid filename")
|
|
return
|
|
}
|
|
if !uploadExtensionAllowed(name, os.Getenv("UPLOAD_ALLOWED_EXTENSIONS")) {
|
|
fail(w, 415, "upload file type denied")
|
|
return
|
|
}
|
|
target := path.Join(dir, name)
|
|
if _, e = root.Lstat(target); !errors.Is(e, os.ErrNotExist) {
|
|
fail(w, 409, "target exists")
|
|
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, e := a.userUsage(u)
|
|
if e != nil || h.Size > maxUpload || used+h.Size > u.Quota {
|
|
fail(w, 413, "quota or upload limit exceeded")
|
|
return
|
|
}
|
|
tmp := path.Join(dir, ".upload-"+store.ID())
|
|
f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if e != nil {
|
|
fail(w, 400, "upload directory unavailable")
|
|
return
|
|
}
|
|
defer root.Remove(tmp)
|
|
n, e := io.Copy(f, io.LimitReader(input, maxUpload+1))
|
|
if e == nil && (n > maxUpload || used+n > u.Quota) {
|
|
e = errors.New("quota exceeded")
|
|
}
|
|
if e == nil {
|
|
e = f.Chown(1000, 1000)
|
|
}
|
|
if e == nil {
|
|
e = f.Sync()
|
|
}
|
|
f.Close()
|
|
if e == nil {
|
|
e = renameNoReplace(root, tmp, target)
|
|
}
|
|
if errors.Is(e, os.ErrExist) {
|
|
fail(w, 409, "target exists")
|
|
return
|
|
}
|
|
if e != nil {
|
|
fail(w, 400, "upload failed")
|
|
return
|
|
}
|
|
locked.Release()
|
|
locked = nil
|
|
a.s.AddAudit(u.ID, "workspace.upload", target, "")
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// Empty policy permits arbitrary workspace files; configured entries are exact
|
|
// case-insensitive extensions, not MIME claims supplied by the client.
|
|
func uploadExtensionAllowed(name, policy string) bool {
|
|
if strings.TrimSpace(policy) == "" {
|
|
return true
|
|
}
|
|
ext := strings.ToLower(path.Ext(name))
|
|
for _, entry := range strings.Split(policy, ",") {
|
|
entry = strings.ToLower(strings.TrimSpace(entry))
|
|
if strings.HasPrefix(entry, ".") && len(entry) > 1 && ext == entry {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *API) userUsage(u store.User) (int64, error) {
|
|
base, e := os.OpenRoot(a.root)
|
|
if e != nil {
|
|
return 0, e
|
|
}
|
|
defer base.Close()
|
|
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
|
|
if errors.Is(e, os.ErrNotExist) {
|
|
return 0, nil
|
|
}
|
|
if e != nil {
|
|
return 0, e
|
|
}
|
|
defer root.Close()
|
|
var total int64
|
|
count := 0
|
|
var walk func(string, int) error
|
|
walk = func(p string, depth int) error {
|
|
count++
|
|
if count > 100000 || depth > 128 {
|
|
return errors.New("tree limit")
|
|
}
|
|
st, e := root.Lstat(p)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if st.Mode().IsRegular() {
|
|
total += st.Size()
|
|
return nil
|
|
}
|
|
if !st.IsDir() {
|
|
return nil
|
|
}
|
|
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer f.Close()
|
|
for {
|
|
ds, readErr := f.ReadDir(128)
|
|
for _, d := range ds {
|
|
if e = walk(path.Join(p, d.Name()), depth+1); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if readErr == io.EOF {
|
|
return nil
|
|
}
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if count >= 100000 {
|
|
return errors.New("tree limit")
|
|
}
|
|
}
|
|
}
|
|
e = walk(".", 0)
|
|
return total, e
|
|
}
|
|
|
|
func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) {
|
|
if r.Method != http.MethodGet {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
p, e := safeRel(r.URL.Query().Get("path"))
|
|
if e != nil {
|
|
fail(w, 400, "invalid path")
|
|
return
|
|
}
|
|
anchor, e := root.Open(".")
|
|
if e != nil {
|
|
fail(w, 404, "not found")
|
|
return
|
|
}
|
|
defer anchor.Close()
|
|
f, e := openDownload(anchor, p)
|
|
if e != nil {
|
|
fail(w, 404, "not found")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
st, e := f.Stat()
|
|
if e == nil && st.IsDir() {
|
|
a.wsDownloadZip(w, r, f, p)
|
|
return
|
|
}
|
|
if e != nil || !st.Mode().IsRegular() {
|
|
fail(w, 400, "invalid file")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
|
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
|
}
|
|
|
|
func openDownload(anchor *os.File, p string) (*os.File, error) {
|
|
fd, err := unix.Openat2(int(anchor.Fd()), p, &unix.OpenHow{
|
|
Flags: unix.O_RDONLY | unix.O_NONBLOCK | unix.O_CLOEXEC,
|
|
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := os.NewFile(uintptr(fd), p)
|
|
st, err := f.Stat()
|
|
if err == nil && (!st.IsDir() && !st.Mode().IsRegular() || st.Mode().IsRegular() && st.Sys().(*syscall.Stat_t).Nlink != 1) {
|
|
err = errors.New("unsafe file")
|
|
}
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
type zipEntry struct {
|
|
name string
|
|
info os.FileInfo
|
|
}
|
|
|
|
func zipManifest(ctx context.Context, anchor *os.File) ([]zipEntry, error) {
|
|
var entries []zipEntry
|
|
var total int64
|
|
var walk func(string, int) error
|
|
walk = func(p string, depth int) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if depth > maxZipDepth || len(entries) >= maxZipEntries {
|
|
return errZipLimit
|
|
}
|
|
f, err := openDownload(anchor, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p != "." {
|
|
if st.Size() < 0 || st.Mode().IsRegular() && st.Size() > maxZipBytes-total {
|
|
return errZipLimit
|
|
}
|
|
if st.Mode().IsRegular() {
|
|
total += st.Size()
|
|
}
|
|
entries = append(entries, zipEntry{p, st})
|
|
}
|
|
if !st.IsDir() {
|
|
return nil
|
|
}
|
|
for {
|
|
ds, err := f.ReadDir(128)
|
|
if err != nil && err != io.EOF {
|
|
return err
|
|
}
|
|
for _, d := range ds {
|
|
if strings.ContainsAny(d.Name(), "\\:") {
|
|
return errors.New("unsafe archive name")
|
|
}
|
|
if e := walk(path.Join(p, d.Name()), depth+1); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
err := walk(".", 0)
|
|
return entries, err
|
|
}
|
|
|
|
type zipContextWriter struct {
|
|
ctx context.Context
|
|
w io.Writer
|
|
}
|
|
|
|
func (w zipContextWriter) Write(p []byte) (int, error) {
|
|
if err := w.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return w.w.Write(p)
|
|
}
|
|
|
|
func streamZip(ctx context.Context, w io.Writer, anchor *os.File, entries []zipEntry) error {
|
|
z := zip.NewWriter(zipContextWriter{ctx, w})
|
|
buf := make([]byte, 32<<10)
|
|
for _, item := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
f, err := openDownload(anchor, item.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = func() error {
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !os.SameFile(st, item.info) || st.Mode() != item.info.Mode() || st.Size() != item.info.Size() || !st.ModTime().Equal(item.info.ModTime()) {
|
|
return errors.New("file changed")
|
|
}
|
|
h, err := zip.FileInfoHeader(st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.Name = item.name
|
|
if st.IsDir() {
|
|
h.Name += "/"
|
|
}
|
|
dst, err := z.CreateHeader(h)
|
|
if err != nil || st.IsDir() {
|
|
return err
|
|
}
|
|
n, err := io.CopyBuffer(dst, io.LimitReader(f, item.info.Size()), buf)
|
|
if err == nil && n != item.info.Size() {
|
|
err = io.ErrUnexpectedEOF
|
|
}
|
|
if err == nil {
|
|
after, statErr := f.Stat()
|
|
if statErr != nil {
|
|
return statErr
|
|
}
|
|
if after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
|
|
return errors.New("file changed")
|
|
}
|
|
}
|
|
return err
|
|
}()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return z.Close()
|
|
}
|
|
|
|
func (a *API) wsDownloadZip(w http.ResponseWriter, r *http.Request, anchor *os.File, p string) {
|
|
select {
|
|
case zipSlots <- struct{}{}:
|
|
defer func() { <-zipSlots }()
|
|
default:
|
|
fail(w, 429, "too many archive downloads")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
|
|
defer cancel()
|
|
controller := http.NewResponseController(w)
|
|
if err := controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
fail(w, 500, "download unavailable")
|
|
return
|
|
}
|
|
defer controller.SetWriteDeadline(time.Time{})
|
|
entries, err := zipManifest(ctx, anchor)
|
|
if err != nil {
|
|
code := 400
|
|
if errors.Is(err, errZipLimit) {
|
|
code = 413
|
|
}
|
|
fail(w, code, "cannot archive directory")
|
|
return
|
|
}
|
|
name := path.Base(p)
|
|
if p == "." {
|
|
name = "workspace"
|
|
}
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + ".zip"}))
|
|
if err := streamZip(ctx, w, anchor, entries); err != nil {
|
|
// A failed stream must not become a successful, silently incomplete archive.
|
|
panic(http.ErrAbortHandler)
|
|
}
|
|
}
|
|
|
|
const maxDirectoryEntries = 10000
|
|
|
|
var errDirectoryLimit = errors.New("directory entry limit exceeded")
|
|
|
|
func readRootDir(root *os.Root, p string) ([]os.DirEntry, error) {
|
|
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
defer f.Close()
|
|
entries, err := f.ReadDir(maxDirectoryEntries + 1)
|
|
if len(entries) > maxDirectoryEntries {
|
|
return nil, errDirectoryLimit
|
|
}
|
|
if err == io.EOF {
|
|
err = nil
|
|
}
|
|
return entries, err
|
|
}
|