Files
DeepSeek-Reasonix/internal/serve/attachments.go
T
yhh b18d8ef3aa feat(studio): storage relocation, composer intake, and the checkpoint agreement
Accumulated work from the branch, carried across the rebase onto origin/studio
and merged with what landed there in the meantime.

Storage: a workspace can name where its state lives, and the server tracks the
one relocation it may be running (internal/storage, config/storage_roots,
serve/storage, the Storage panel).

Composer intake: what a drop or a long paste becomes is one pure function —
payload in, action out — so "what happens if I drop a folder" is a test rather
than a branch of an onDrop handler. It sits on the drop router that landed
upstream: that one answers which pane a drop hit, this one answers what it
means and says so before the pointer is released. A paste long enough to bury
the composer becomes a chip beside it instead.

Session: the checkpoint agreement and the lineage/torn-log recovery cases,
plus the conflict diagnostic learning where two transcripts parted and which
roles stood there — a position and a role tell a local reshape from a foreign
write without recording any content.

Model refs: the default-model write checks the live catalog, so an extension
model the runtime knows can be persisted as the default.
2026-08-19 23:52:04 -07:00

90 lines
3.2 KiB
Go

package serve
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"reasonix/internal/control"
)
// Bytes ride into a turn as path references, exactly as the CLI's do. This is
// the door for a client that has bytes and no path: a browser tab, and the
// clipboard everywhere. A window that knows the path uses POST /drop and copies
// nothing. JSON rather than raw bytes because csrfGuard admits nothing else.
// control enforces the real per-kind limit once these are decoded.
const maxAttachmentUpload = 25 << 20
// attachmentBytes takes the bytes from whichever half the client had. A desktop
// drop reports a path and never the bytes, so the host reads the file it was
// pointed at — bounded to a regular file under the size cap, and still handed to
// the same saver, which admits nothing that does not sniff as a real image.
func attachmentBytes(path, data string) ([]byte, error) {
if strings.TrimSpace(path) == "" {
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, errors.New("attachment data must be base64")
}
return raw, nil
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("read attachment: %w", err)
}
// A directory or a device would read as something no image sniff would
// admit, but refusing here says what was wrong instead of what it was not.
if !info.Mode().IsRegular() {
return nil, errors.New("attachment path is not a regular file")
}
if info.Size() > maxAttachmentUpload {
return nil, errors.New("attachment is larger than 10 MB")
}
return os.ReadFile(path)
}
func (s *Server) attachments(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, (maxAttachmentUpload/3)*4+1024)
var body struct {
Mime string `json:"mime"`
// Name only supplies the extension the bytes are stored under; the
// stored name is generated either way.
Name string `json:"name"`
Data string `json:"data"`
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "read attachment: "+err.Error(), http.StatusBadRequest)
return
}
raw, err := attachmentBytes(body.Path, body.Data)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
root := s.ctl().WorkspaceRoot()
// The declared type picks the door, not the name: bytes claiming to be a
// picture must prove it, because a turn referencing them asks a model to
// look at them. Everything else is stored as the file it says it is.
save := func() (string, error) { return control.SaveAttachmentBytesInRoot(root, body.Name, raw) }
if body.Name == "" || strings.HasPrefix(strings.ToLower(body.Mime), "image/") {
save = func() (string, error) { return control.SaveImageBytesInRoot(root, body.Mime, raw) }
}
saved, err := save()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// The turn parser resolves "@<path>"; hand back the exact token so the
// client never reconstructs the reference syntax itself.
rel := saved
if r, relErr := filepath.Rel(root, saved); relErr == nil && !strings.HasPrefix(r, "..") {
rel = filepath.ToSlash(r)
}
writeJSON(w, map[string]any{"path": rel, "ref": "@" + rel, "image": control.RefIsImage(rel)})
}