返回 DeepSeek-Reasonix
session_export.go
根目录 / internal / serve / session_export.go
1 package serve
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/control"
14 "reasonix/internal/session"
15 "reasonix/internal/sessionexport"
16 )
17
18 // fixedSessionExportSource resolves one explicit export identity without
19 // requiring that identity to remain the foreground writer. The service owner,
20 // and therefore the storage root and HostID, always comes from this process.
21 func (s *Server) fixedSessionExportSource(w http.ResponseWriter, r *http.Request, snapshotRef session.SessionRef) (*session.Query, session.SessionRef, *control.Controller, bool) {
22 s.bindMu.Lock()
23 identity, ok := s.ctl().(control.IdentityLifecycle)
24 if !ok || !identity.UsesExclusiveSession() {
25 s.bindMu.Unlock()
26 http.Error(w, "canonical session export is unavailable", http.StatusNotImplemented)
27 return nil, session.SessionRef{}, nil, false
28 }
29 service := identity.SessionService()
30 if service == nil || service.Query() == nil {
31 s.bindMu.Unlock()
32 http.Error(w, "canonical session identity is unavailable", http.StatusConflict)
33 return nil, session.SessionRef{}, nil, false
34 }
35 current, bound := identity.SessionRef()
36 controller, _ := s.ctl().(*control.Controller)
37 legacyExpected := strings.TrimSpace(r.Header.Get(expectedSessionPathHeader))
38 if legacyExpected != "" && !strings.HasPrefix(legacyExpected, remoteSessionIDQueryPrefix) {
39 if err := s.expectedSessionPathErrorLocked(legacyExpected); err != nil {
40 s.bindMu.Unlock()
41 http.Error(w, err.Error(), http.StatusConflict)
42 return nil, session.SessionRef{}, nil, false
43 }
44 }
45 query := service.Query()
46 hostID := service.HostID()
47 s.bindMu.Unlock()
48
49 targetID := ""
50 addTarget := func(raw string) bool {
51 candidate, err := canonicalSessionExportID(raw)
52 if err != nil {
53 http.Error(w, "invalid export target identity", http.StatusBadRequest)
54 return false
55 }
56 if candidate == "" {
57 return true
58 }
59 if targetID != "" && targetID != candidate {
60 http.Error(w, "export target identities conflict", http.StatusConflict)
61 return false
62 }
63 targetID = candidate
64 return true
65 }
66 if !addTarget(r.URL.Query().Get("sessionId")) ||
67 !addTarget(r.Header.Get(expectedSessionIDHeader)) ||
68 !addTarget(func() string {
69 if strings.HasPrefix(legacyExpected, remoteSessionIDQueryPrefix) {
70 return legacyExpected
71 }
72 return ""
73 }()) ||
74 !addTarget(snapshotRef.SessionID) {
75 return nil, session.SessionRef{}, nil, false
76 }
77 if snapshotRef.HostID != "" && snapshotRef.HostID != hostID {
78 http.Error(w, "export target host changed", http.StatusConflict)
79 return nil, session.SessionRef{}, nil, false
80 }
81 var ref session.SessionRef
82 if targetID == "" {
83 if !bound {
84 http.Error(w, "canonical session identity is unavailable", http.StatusConflict)
85 return nil, session.SessionRef{}, nil, false
86 }
87 ref = current
88 } else {
89 var err error
90 ref, err = query.ResolveSessionID(r.Context(), targetID)
91 if err != nil {
92 http.Error(w, "session export source is unavailable", http.StatusConflict)
93 return nil, session.SessionRef{}, nil, false
94 }
95 }
96 if _, err := query.Stat(r.Context(), ref); err != nil {
97 http.Error(w, "session export source is unavailable", http.StatusConflict)
98 return nil, session.SessionRef{}, nil, false
99 }
100 if !bound || current != ref {
101 controller = nil
102 }
103 return query, ref, controller, true
104 }
105
106 // canonicalSessionExportID terminates HTTP taint at the storage identity
107 // boundary. A remote caller selects an opaque session name; it never supplies
108 // a path beneath the service-owned storage root.
109 func canonicalSessionExportID(raw string) (string, error) {
110 candidate := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), remoteSessionIDQueryPrefix))
111 if candidate == "" {
112 return "", nil
113 }
114 if err := session.ValidateSessionID(candidate); err != nil {
115 return "", err
116 }
117 // filepath.Base is deliberately applied after the stricter cross-platform
118 // validator so static analysis and future persistence implementations both
119 // receive a single local path component.
120 return filepath.Base(candidate), nil
121 }
122
123 func (s *Server) sessionExportSnapshot(w http.ResponseWriter, r *http.Request) {
124 query, ref, _, ok := s.fixedSessionExportSource(w, r, session.SessionRef{})
125 if !ok {
126 return
127 }
128 var snapshot session.ExportSnapshot
129 var err error
130 if r.URL.Query().Get("diagnostic") == "1" {
131 snapshot, err = query.CaptureDiagnosticSnapshot(r.Context(), ref)
132 } else {
133 snapshot, err = query.CaptureExportSnapshot(r.Context(), ref)
134 }
135 if err != nil {
136 http.Error(w, "Unable to capture session export", http.StatusConflict)
137 return
138 }
139 w.Header().Set("Content-Type", "application/json")
140 w.Header().Set("Cache-Control", "no-store")
141 _ = json.NewEncoder(w).Encode(snapshot)
142 }
143
144 // The snapshot is a stateless read capability within the authenticated session.
145 // No remote job/temporary files survive the response, including disconnects.
146 func (s *Server) sessionExportDocument(w http.ResponseWriter, r *http.Request) {
147 var request struct {
148 Snapshot session.ExportSnapshot `json:"snapshot"`
149 Format string `json:"format"`
150 }
151 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&request); err != nil {
152 http.Error(w, "invalid export request", http.StatusBadRequest)
153 return
154 }
155 documentName := ""
156 switch request.Format {
157 case "markdown":
158 documentName = "markdown"
159 case "json":
160 documentName = "json"
161 case "blocks":
162 documentName = "blocks"
163 default:
164 http.Error(w, "invalid export format", http.StatusBadRequest)
165 return
166 }
167 query, ref, _, ok := s.fixedSessionExportSource(w, r, request.Snapshot.Ref)
168 if !ok {
169 return
170 }
171 // Filesystem identity comes only from the canonical binding. Rebuilding the
172 // snapshot prevents request data from becoming a path component even if the
173 // equality check above is weakened later.
174 snapshot := session.ExportSnapshot{
175 ReadIncomplete: request.Snapshot.ReadIncomplete,
176 Ref: ref,
177 StorageGeneration: request.Snapshot.StorageGeneration,
178 SnapshotSequence: request.Snapshot.SnapshotSequence,
179 AcceptedThrough: request.Snapshot.AcceptedThrough,
180 DurableThrough: request.Snapshot.DurableThrough,
181 CapturedAt: request.Snapshot.CapturedAt,
182 Title: request.Snapshot.Title,
183 }
184 dir, err := os.MkdirTemp("", "reasonix-session-export-")
185 if err != nil {
186 http.Error(w, "export staging failed", http.StatusInternalServerError)
187 return
188 }
189 defer os.RemoveAll(dir)
190 doc, err := sessionexport.BuildForRef(r.Context(), query, ref, snapshot, dir, nil)
191 if err != nil {
192 http.Error(w, "session export failed or source changed", http.StatusConflict)
193 return
194 }
195 file, err := os.Open(filepath.Join(dir, documentName))
196 if err != nil {
197 http.Error(w, "export unavailable", http.StatusInternalServerError)
198 return
199 }
200 defer file.Close()
201 w.Header().Set("Content-Type", "application/octet-stream")
202 w.Header().Set("Cache-Control", "no-store")
203 // Content-Length lets a client distinguish EOF from a truncated transport.
204 info, err := file.Stat()
205 if err != nil {
206 http.Error(w, "export unavailable", http.StatusInternalServerError)
207 return
208 }
209 w.Header().Set("X-Reasonix-Export-Records", fmt.Sprint(doc.Records))
210 w.Header().Set("Content-Length", fmt.Sprint(info.Size()))
211 _, _ = io.Copy(w, file)
212 }
213
214 func (s *Server) sessionExportDiagnostic(w http.ResponseWriter, r *http.Request) {
215 var extra map[string]any
216 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&extra); err != nil {
217 http.Error(w, "invalid observation", http.StatusBadRequest)
218 return
219 }
220 var snapshot session.ExportSnapshot
221 if value, present := extra["exportSnapshot"]; present {
222 raw, err := json.Marshal(value)
223 if err != nil || json.Unmarshal(raw, &snapshot) != nil {
224 http.Error(w, "invalid export snapshot", http.StatusBadRequest)
225 return
226 }
227 }
228 query, ref, controller, ok := s.fixedSessionExportSource(w, r, snapshot.Ref)
229 if !ok {
230 return
231 }
232 if snapshot.Ref.SessionID == "" {
233 var err error
234 snapshot, err = query.CaptureDiagnosticSnapshot(r.Context(), ref)
235 if err != nil {
236 http.Error(w, "unable to capture diagnostic snapshot", http.StatusConflict)
237 return
238 }
239 extra["exportSnapshot"] = snapshot
240 }
241 s.bindMu.Lock()
242 caps := s.capabilities()
243 s.bindMu.Unlock()
244 // Stage before sending headers so a failed writer never becomes a valid-looking
245 // truncated JSON download. Flush failures remain evidence inside the document.
246 file, err := os.CreateTemp("", "reasonix-diagnostic-")
247 if err != nil {
248 http.Error(w, "diagnostics unavailable", http.StatusInternalServerError)
249 return
250 }
251 defer os.Remove(file.Name())
252 defer file.Close()
253 allowed := map[string]any{}
254 for _, name := range []string{"sessionIdentity", "exportSnapshot", "frontendObservation", "readDiagnostics"} {
255 if value, ok := extra[name]; ok {
256 allowed[name] = value
257 }
258 }
259 metadata := control.GoalDiagnosticMetadata{Capabilities: caps}
260 if s.sessionDiagnosticControllerCurrent(controller, ref) {
261 err = controller.WriteSessionDiagnostics(r.Context(), file, metadata, allowed)
262 if !s.sessionDiagnosticControllerCurrent(controller, ref) {
263 resetErr := file.Truncate(0)
264 if resetErr == nil {
265 _, resetErr = file.Seek(0, io.SeekStart)
266 }
267 if resetErr == nil {
268 err = control.WriteColdSessionDiagnostics(r.Context(), file, query, snapshot, metadata, allowed)
269 } else {
270 err = errors.Join(err, resetErr)
271 }
272 }
273 } else {
274 err = control.WriteColdSessionDiagnostics(r.Context(), file, query, snapshot, metadata, allowed)
275 }
276 if err != nil {
277 http.Error(w, "diagnostic export failed", http.StatusInternalServerError)
278 return
279 }
280 info, err := file.Stat()
281 if err != nil {
282 http.Error(w, "diagnostic export failed", http.StatusInternalServerError)
283 return
284 }
285 if _, err = file.Seek(0, io.SeekStart); err != nil {
286 http.Error(w, "diagnostic export failed", http.StatusInternalServerError)
287 return
288 }
289 w.Header().Set("Content-Type", "application/json")
290 w.Header().Set("Content-Length", fmt.Sprint(info.Size()))
291 w.Header().Set("Cache-Control", "no-store")
292 _, _ = io.Copy(w, file)
293 }
294
295 func (s *Server) sessionDiagnosticControllerCurrent(controller *control.Controller, ref session.SessionRef) bool {
296 if controller == nil {
297 return false
298 }
299 s.bindMu.Lock()
300 defer s.bindMu.Unlock()
301 current, ok := s.ctl().(*control.Controller)
302 if !ok || current != controller {
303 return false
304 }
305 identity, ok := s.ctl().(control.IdentityLifecycle)
306 if !ok {
307 return false
308 }
309 boundRef, bound := identity.SessionRef()
310 return bound && boundRef == ref
311 }
312
313 func (s *Server) sessionExportValidate(w http.ResponseWriter, r *http.Request) {
314 var snapshot session.ExportSnapshot
315 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&snapshot); err != nil {
316 http.Error(w, "invalid snapshot", http.StatusBadRequest)
317 return
318 }
319 query, ref, _, ok := s.fixedSessionExportSource(w, r, snapshot.Ref)
320 if !ok {
321 return
322 }
323 if query.ValidateExportSourceForRef(ref, snapshot) != nil {
324 http.Error(w, "export source changed", http.StatusConflict)
325 return
326 }
327 w.WriteHeader(http.StatusNoContent)
328 }
329
329 lines GO