+342
-497
internal/api/handlers.go
+342
-497
internal/api/handlers.go
···
1
package api
2
3
import (
4
-
"bufio"
5
-
"bytes"
6
"crypto/sha256"
7
"encoding/hex"
8
"encoding/json"
···
17
"github.com/atscan/atscanner/internal/plc"
18
"github.com/atscan/atscanner/internal/storage"
19
"github.com/gorilla/mux"
20
-
"github.com/klauspost/compress/zstd"
21
)
22
23
-
// ====================
24
-
// Endpoint Handlers (new)
25
-
// ====================
26
27
-
func (s *Server) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
28
-
ctx := r.Context()
29
30
-
filter := &storage.EndpointFilter{}
31
32
-
if typ := r.URL.Query().Get("type"); typ != "" {
33
-
filter.Type = typ
34
-
}
35
36
-
if status := r.URL.Query().Get("status"); status != "" {
37
-
filter.Status = status
38
}
39
40
-
if minUserCount := r.URL.Query().Get("min_user_count"); minUserCount != "" {
41
-
if count, err := strconv.ParseInt(minUserCount, 10, 64); err == nil {
42
-
filter.MinUserCount = count
43
}
44
}
45
46
-
if limit := r.URL.Query().Get("limit"); limit != "" {
47
-
if l, err := strconv.Atoi(limit); err == nil {
48
-
filter.Limit = l
49
-
}
50
}
51
52
-
if offset := r.URL.Query().Get("offset"); offset != "" {
53
-
if o, err := strconv.Atoi(offset); err == nil {
54
-
filter.Offset = o
55
-
}
56
}
57
58
-
endpoints, err := s.db.GetEndpoints(ctx, filter)
59
if err != nil {
60
-
http.Error(w, err.Error(), http.StatusInternalServerError)
61
return
62
}
63
64
-
// Convert status codes to strings for API
65
response := make([]map[string]interface{}, len(endpoints))
66
for i, ep := range endpoints {
67
-
response[i] = map[string]interface{}{
68
-
"id": ep.ID,
69
-
"endpoint_type": ep.EndpointType,
70
-
"endpoint": ep.Endpoint,
71
-
"discovered_at": ep.DiscoveredAt,
72
-
"last_checked": ep.LastChecked,
73
-
"status": statusToString(ep.Status),
74
-
"user_count": ep.UserCount,
75
-
}
76
}
77
78
-
respondJSON(w, response)
79
}
80
81
func (s *Server) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
82
-
ctx := r.Context()
83
vars := mux.Vars(r)
84
endpoint := vars["endpoint"]
85
-
86
-
// Get type from query param, default to "pds" for backward compatibility
87
endpointType := r.URL.Query().Get("type")
88
if endpointType == "" {
89
endpointType = "pds"
90
}
91
92
-
ep, err := s.db.GetEndpoint(ctx, endpoint, endpointType)
93
if err != nil {
94
-
http.Error(w, "Endpoint not found", http.StatusNotFound)
95
return
96
}
97
98
-
// Get recent scans
99
-
scans, _ := s.db.GetEndpointScans(ctx, ep.ID, 10)
100
101
-
response := map[string]interface{}{
102
-
"id": ep.ID,
103
-
"endpoint_type": ep.EndpointType,
104
-
"endpoint": ep.Endpoint,
105
-
"discovered_at": ep.DiscoveredAt,
106
-
"last_checked": ep.LastChecked,
107
-
"status": statusToString(ep.Status),
108
-
"user_count": ep.UserCount,
109
-
"recent_scans": scans,
110
-
}
111
112
-
respondJSON(w, response)
113
}
114
115
func (s *Server) handleGetEndpointStats(w http.ResponseWriter, r *http.Request) {
116
-
ctx := r.Context()
117
-
118
-
stats, err := s.db.GetEndpointStats(ctx)
119
if err != nil {
120
-
http.Error(w, err.Error(), http.StatusInternalServerError)
121
return
122
}
123
-
124
-
respondJSON(w, stats)
125
}
126
127
-
// ====================
128
-
// DID Handlers
129
-
// ====================
130
131
func (s *Server) handleGetDID(w http.ResponseWriter, r *http.Request) {
132
-
ctx := r.Context()
133
vars := mux.Vars(r)
134
did := vars["did"]
135
136
-
bundles, err := s.db.GetBundlesForDID(ctx, did)
137
if err != nil {
138
-
http.Error(w, err.Error(), http.StatusInternalServerError)
139
return
140
}
141
142
if len(bundles) == 0 {
143
-
http.Error(w, "DID not found in bundles", http.StatusNotFound)
144
return
145
}
146
147
lastBundle := bundles[len(bundles)-1]
148
-
149
-
// Compute file path
150
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", lastBundle.BundleNumber))
151
-
152
-
operations, err := s.loadBundleOperations(filePath)
153
if err != nil {
154
-
http.Error(w, fmt.Sprintf("failed to load bundle: %v", err), http.StatusInternalServerError)
155
return
156
}
157
158
// Find latest operation for this DID
159
-
var latestOp *plc.PLCOperation
160
-
for i := len(operations) - 1; i >= 0; i-- {
161
-
if operations[i].DID == did {
162
-
latestOp = &operations[i]
163
-
break
164
}
165
}
166
167
-
if latestOp == nil {
168
-
http.Error(w, "DID operation not found", http.StatusNotFound)
169
-
return
170
-
}
171
-
172
-
respondJSON(w, latestOp)
173
}
174
175
func (s *Server) handleGetDIDHistory(w http.ResponseWriter, r *http.Request) {
176
-
ctx := r.Context()
177
vars := mux.Vars(r)
178
did := vars["did"]
179
180
-
bundles, err := s.db.GetBundlesForDID(ctx, did)
181
if err != nil {
182
-
http.Error(w, err.Error(), http.StatusInternalServerError)
183
return
184
}
185
186
if len(bundles) == 0 {
187
-
http.Error(w, "DID not found in bundles", http.StatusNotFound)
188
return
189
}
190
···
192
var currentOp *plc.PLCOperation
193
194
for _, bundle := range bundles {
195
-
// Compute file path
196
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundle.BundleNumber))
197
-
198
-
operations, err := s.loadBundleOperations(filePath)
199
if err != nil {
200
log.Error("Warning: failed to load bundle: %v", err)
201
continue
202
}
203
204
-
for _, op := range operations {
205
if op.DID == did {
206
entry := plc.DIDHistoryEntry{
207
Operation: op,
···
213
}
214
}
215
216
-
history := plc.DIDHistory{
217
DID: did,
218
Current: currentOp,
219
Operations: allOperations,
220
-
}
221
-
222
-
respondJSON(w, history)
223
}
224
225
-
// ====================
226
-
// PLC Bundle Handlers
227
-
// ====================
228
229
func (s *Server) handleGetPLCBundle(w http.ResponseWriter, r *http.Request) {
230
-
ctx := r.Context()
231
-
vars := mux.Vars(r)
232
233
-
bundleNumber, err := strconv.Atoi(vars["number"])
234
if err != nil {
235
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
236
return
237
}
238
239
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
240
if err != nil {
241
-
http.Error(w, "bundle not found", http.StatusNotFound)
242
return
243
}
244
245
-
response := map[string]interface{}{
246
-
"plc_bundle_number": bundle.BundleNumber,
247
-
"start_time": bundle.StartTime,
248
-
"end_time": bundle.EndTime,
249
-
"operation_count": plc.BUNDLE_SIZE,
250
-
"did_count": len(bundle.DIDs),
251
-
"hash": bundle.Hash,
252
-
"compressed_hash": bundle.CompressedHash,
253
-
"compressed_size": bundle.CompressedSize,
254
-
"prev_bundle_hash": bundle.PrevBundleHash,
255
-
"created_at": bundle.CreatedAt,
256
-
}
257
-
258
-
respondJSON(w, response)
259
}
260
261
func (s *Server) handleGetPLCBundleDIDs(w http.ResponseWriter, r *http.Request) {
262
-
ctx := r.Context()
263
-
vars := mux.Vars(r)
264
265
-
bundleNumber, err := strconv.Atoi(vars["number"])
266
if err != nil {
267
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
268
return
269
}
270
271
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
272
if err != nil {
273
-
http.Error(w, "bundle not found", http.StatusNotFound)
274
return
275
}
276
277
-
respondJSON(w, map[string]interface{}{
278
"plc_bundle_number": bundle.BundleNumber,
279
"did_count": len(bundle.DIDs),
280
"dids": bundle.DIDs,
···
282
}
283
284
func (s *Server) handleDownloadPLCBundle(w http.ResponseWriter, r *http.Request) {
285
-
ctx := r.Context()
286
-
vars := mux.Vars(r)
287
288
-
bundleNumber, err := strconv.Atoi(vars["number"])
289
if err != nil {
290
-
http.Error(w, "invalid bundle number", http.StatusBadRequest)
291
return
292
}
293
294
-
// Check if client wants uncompressed data
295
-
compressed := true
296
-
if r.URL.Query().Get("compressed") == "false" {
297
-
compressed = false
298
-
}
299
300
-
// Verify bundle exists in database
301
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
302
if err != nil {
303
-
http.Error(w, "bundle not found", http.StatusNotFound)
304
return
305
}
306
307
-
// Build file path
308
-
filePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundleNumber))
309
310
-
// Check if file exists
311
-
fileInfo, err := os.Stat(filePath)
312
if err != nil {
313
-
if os.IsNotExist(err) {
314
-
http.Error(w, "bundle file not found on disk", http.StatusNotFound)
315
-
return
316
-
}
317
-
http.Error(w, fmt.Sprintf("error accessing bundle file: %v", err), http.StatusInternalServerError)
318
return
319
}
320
321
-
// Set common headers
322
-
w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundleNumber))
323
-
w.Header().Set("X-Bundle-Hash", bundle.Hash)
324
-
w.Header().Set("X-Bundle-Compressed-Hash", bundle.CompressedHash)
325
-
w.Header().Set("X-Bundle-Start-Time", bundle.StartTime.Format(time.RFC3339Nano))
326
-
w.Header().Set("X-Bundle-End-Time", bundle.EndTime.Format(time.RFC3339Nano))
327
-
w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE))
328
-
w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(bundle.DIDs)))
329
330
-
if compressed {
331
-
// Serve compressed file
332
-
file, err := os.Open(filePath)
333
-
if err != nil {
334
-
http.Error(w, fmt.Sprintf("error opening bundle file: %v", err), http.StatusInternalServerError)
335
-
return
336
-
}
337
-
defer file.Close()
338
339
-
w.Header().Set("Content-Type", "application/zstd")
340
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundleNumber))
341
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
342
-
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
343
344
-
http.ServeContent(w, r, filepath.Base(filePath), bundle.CreatedAt, file)
345
-
} else {
346
-
// Serve uncompressed data
347
-
compressedData, err := os.ReadFile(filePath)
348
-
if err != nil {
349
-
http.Error(w, fmt.Sprintf("error reading bundle file: %v", err), http.StatusInternalServerError)
350
-
return
351
-
}
352
353
-
// Decompress
354
-
decoder, err := zstd.NewReader(nil)
355
-
if err != nil {
356
-
http.Error(w, fmt.Sprintf("error creating decompressor: %v", err), http.StatusInternalServerError)
357
-
return
358
-
}
359
-
defer decoder.Close()
360
361
-
decompressed, err := decoder.DecodeAll(compressedData, nil)
362
-
if err != nil {
363
-
http.Error(w, fmt.Sprintf("error decompressing bundle: %v", err), http.StatusInternalServerError)
364
-
return
365
-
}
366
367
-
// Set headers for uncompressed data
368
-
w.Header().Set("Content-Type", "application/jsonl")
369
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundleNumber))
370
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(decompressed)))
371
-
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
372
-
w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", len(decompressed)))
373
-
w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(len(decompressed))/float64(fileInfo.Size())))
374
375
-
// Write decompressed data
376
-
w.WriteHeader(http.StatusOK)
377
-
w.Write(decompressed)
378
}
379
}
380
381
func (s *Server) handleGetPLCBundles(w http.ResponseWriter, r *http.Request) {
382
-
ctx := r.Context()
383
-
384
-
limit := 50
385
-
if l := r.URL.Query().Get("limit"); l != "" {
386
-
if parsed, err := strconv.Atoi(l); err == nil {
387
-
limit = parsed
388
-
}
389
-
}
390
391
-
bundles, err := s.db.GetBundles(ctx, limit)
392
if err != nil {
393
-
http.Error(w, err.Error(), http.StatusInternalServerError)
394
return
395
}
396
397
response := make([]map[string]interface{}, len(bundles))
398
for i, bundle := range bundles {
399
-
response[i] = map[string]interface{}{
400
-
"plc_bundle_number": bundle.BundleNumber,
401
-
"start_time": bundle.StartTime,
402
-
"end_time": bundle.EndTime,
403
-
"operation_count": plc.BUNDLE_SIZE,
404
-
"did_count": len(bundle.DIDs),
405
-
"hash": bundle.Hash,
406
-
"compressed_hash": bundle.CompressedHash,
407
-
"compressed_size": bundle.CompressedSize,
408
-
"prev_bundle_hash": bundle.PrevBundleHash,
409
-
}
410
}
411
412
-
respondJSON(w, response)
413
}
414
415
func (s *Server) handleGetPLCBundleStats(w http.ResponseWriter, r *http.Request) {
416
-
ctx := r.Context()
417
418
-
count, size, err := s.db.GetBundleStats(ctx)
419
if err != nil {
420
-
http.Error(w, err.Error(), http.StatusInternalServerError)
421
return
422
}
423
424
-
respondJSON(w, map[string]interface{}{
425
"plc_bundle_count": count,
426
"total_size": size,
427
"total_size_mb": float64(size) / 1024 / 1024,
428
})
429
}
430
431
-
// ====================
432
-
// Mempool Handlers
433
-
// ====================
434
435
func (s *Server) handleGetMempoolStats(w http.ResponseWriter, r *http.Request) {
436
ctx := r.Context()
437
438
count, err := s.db.GetMempoolCount(ctx)
439
if err != nil {
440
-
http.Error(w, err.Error(), http.StatusInternalServerError)
441
return
442
}
443
444
-
response := map[string]interface{}{
445
"operation_count": count,
446
"can_create_bundle": count >= plc.BUNDLE_SIZE,
447
}
448
449
-
// Get mempool start time (first item)
450
if count > 0 {
451
-
firstOp, err := s.db.GetFirstMempoolOperation(ctx)
452
-
if err == nil && firstOp != nil {
453
-
response["mempool_start_time"] = firstOp.CreatedAt
454
455
-
// Calculate estimated next bundle time
456
if count < plc.BUNDLE_SIZE {
457
-
lastOp, err := s.db.GetLastMempoolOperation(ctx)
458
-
if err == nil && lastOp != nil {
459
-
// Calculate rate of operations per second
460
timeSpan := lastOp.CreatedAt.Sub(firstOp.CreatedAt).Seconds()
461
-
462
if timeSpan > 0 {
463
opsPerSecond := float64(count) / timeSpan
464
-
465
if opsPerSecond > 0 {
466
remainingOps := plc.BUNDLE_SIZE - count
467
secondsNeeded := float64(remainingOps) / opsPerSecond
468
-
estimatedTime := time.Now().Add(time.Duration(secondsNeeded) * time.Second)
469
-
470
-
response["estimated_next_bundle_time"] = estimatedTime
471
-
response["operations_needed"] = remainingOps
472
-
response["current_rate_per_second"] = opsPerSecond
473
}
474
}
475
}
476
} else {
477
-
// Bundle can be created now
478
-
response["estimated_next_bundle_time"] = time.Now()
479
-
response["operations_needed"] = 0
480
}
481
}
482
} else {
483
-
response["mempool_start_time"] = nil
484
-
response["estimated_next_bundle_time"] = nil
485
}
486
487
-
respondJSON(w, response)
488
}
489
490
-
// ====================
491
-
// PLC Metrics Handlers
492
-
// ====================
493
494
func (s *Server) handleGetPLCMetrics(w http.ResponseWriter, r *http.Request) {
495
-
ctx := r.Context()
496
-
497
-
limit := 10
498
-
if l := r.URL.Query().Get("limit"); l != "" {
499
-
if parsed, err := strconv.Atoi(l); err == nil {
500
-
limit = parsed
501
-
}
502
-
}
503
504
-
metrics, err := s.db.GetPLCMetrics(ctx, limit)
505
if err != nil {
506
-
http.Error(w, err.Error(), http.StatusInternalServerError)
507
return
508
}
509
510
-
respondJSON(w, metrics)
511
}
512
513
-
// ====================
514
-
// Verification Handlers
515
-
// ====================
516
517
func (s *Server) handleVerifyPLCBundle(w http.ResponseWriter, r *http.Request) {
518
-
ctx := r.Context()
519
vars := mux.Vars(r)
520
-
bundleNumberStr := vars["bundleNumber"]
521
522
-
bundleNumber, err := strconv.Atoi(bundleNumberStr)
523
if err != nil {
524
-
http.Error(w, "Invalid bundle number", http.StatusBadRequest)
525
return
526
}
527
528
-
// Get bundle from DB
529
-
bundle, err := s.db.GetBundleByNumber(ctx, bundleNumber)
530
if err != nil {
531
-
http.Error(w, "Bundle not found", http.StatusNotFound)
532
return
533
}
534
535
-
// Get previous bundle for boundary state
536
var after string
537
var prevBoundaryCIDs map[string]bool
538
539
-
if bundleNumber > 1 {
540
-
prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNumber-1)
541
if err != nil {
542
-
http.Error(w, "Failed to get previous bundle", http.StatusInternalServerError)
543
-
return
544
}
545
546
after = prevBundle.EndTime.Format("2006-01-02T15:04:05.000Z")
547
548
-
// Convert stored boundary CIDs to map
549
if len(prevBundle.BoundaryCIDs) > 0 {
550
prevBoundaryCIDs = make(map[string]bool)
551
for _, cid := range prevBundle.BoundaryCIDs {
···
554
}
555
}
556
557
-
// Collect remote operations (may need multiple fetches for large bundles)
558
var allRemoteOps []plc.PLCOperation
559
seenCIDs := make(map[string]bool)
560
561
-
// Track boundary CIDs
562
for cid := range prevBoundaryCIDs {
563
seenCIDs[cid] = true
564
}
565
566
currentAfter := after
567
-
maxFetches := 20 // Enough for up to 20k operations
568
569
for fetchNum := 0; fetchNum < maxFetches && len(allRemoteOps) < plc.BUNDLE_SIZE; fetchNum++ {
570
-
// Fetch from PLC directory
571
batch, err := s.plcClient.Export(ctx, plc.ExportOptions{
572
Count: 1000,
573
After: currentAfter,
574
})
575
-
if err != nil {
576
-
http.Error(w, fmt.Sprintf("Failed to fetch from PLC directory: %v", err), http.StatusInternalServerError)
577
-
return
578
-
}
579
-
580
-
if len(batch) == 0 {
581
break
582
}
583
584
-
// Deduplicate and add unique operations
585
for _, op := range batch {
586
if !seenCIDs[op.CID] {
587
seenCIDs[op.CID] = true
···
592
}
593
}
594
595
-
// Update cursor for next fetch
596
if len(batch) > 0 {
597
lastOp := batch[len(batch)-1]
598
currentAfter = lastOp.CreatedAt.Format("2006-01-02T15:04:05.000Z")
599
}
600
601
-
// If we got less than 1000, we've reached the end
602
if len(batch) < 1000 {
603
break
604
}
605
}
606
607
-
// Trim to exact bundle size
608
if len(allRemoteOps) > plc.BUNDLE_SIZE {
609
allRemoteOps = allRemoteOps[:plc.BUNDLE_SIZE]
610
}
611
612
-
// Compute remote hash (uncompressed JSONL)
613
-
remoteHash, err := computeRemoteOperationsHash(allRemoteOps)
614
-
if err != nil {
615
-
http.Error(w, fmt.Sprintf("Failed to compute remote hash: %v", err), http.StatusInternalServerError)
616
-
return
617
-
}
618
-
619
-
// Compare hashes (use uncompressed hash)
620
-
verified := bundle.Hash == remoteHash
621
-
622
-
respondJSON(w, map[string]interface{}{
623
-
"bundle_number": bundleNumber,
624
-
"verified": verified,
625
-
"local_hash": bundle.Hash,
626
-
"remote_hash": remoteHash,
627
-
"local_op_count": plc.BUNDLE_SIZE,
628
-
"remote_op_count": len(allRemoteOps),
629
-
"boundary_cids_used": len(prevBoundaryCIDs),
630
-
})
631
}
632
633
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
634
ctx := r.Context()
635
636
-
// Get last bundle number
637
lastBundle, err := s.db.GetLastBundleNumber(ctx)
638
if err != nil {
639
-
http.Error(w, err.Error(), http.StatusInternalServerError)
640
return
641
}
642
643
if lastBundle == 0 {
644
-
respondJSON(w, map[string]interface{}{
645
"status": "empty",
646
"message": "No bundles to verify",
647
})
648
return
649
}
650
651
-
// Verify chain
652
valid := true
653
var brokenAt int
654
var errorMsg string
···
662
break
663
}
664
665
-
// Verify chain link
666
if i > 1 {
667
prevBundle, err := s.db.GetBundleByNumber(ctx, i-1)
668
if err != nil {
···
681
}
682
}
683
684
-
response := map[string]interface{}{
685
"chain_length": lastBundle,
686
"valid": valid,
687
}
688
689
if !valid {
690
-
response["broken_at"] = brokenAt
691
-
response["error"] = errorMsg
692
}
693
694
-
respondJSON(w, response)
695
}
696
697
func (s *Server) handleGetChainInfo(w http.ResponseWriter, r *http.Request) {
698
ctx := r.Context()
699
700
lastBundle, err := s.db.GetLastBundleNumber(ctx)
701
if err != nil {
702
-
http.Error(w, err.Error(), http.StatusInternalServerError)
703
return
704
}
705
706
if lastBundle == 0 {
707
-
respondJSON(w, map[string]interface{}{
708
"chain_length": 0,
709
"status": "empty",
710
})
···
713
714
firstBundle, _ := s.db.GetBundleByNumber(ctx, 1)
715
lastBundleData, _ := s.db.GetBundleByNumber(ctx, lastBundle)
716
-
717
count, size, _ := s.db.GetBundleStats(ctx)
718
719
-
respondJSON(w, map[string]interface{}{
720
"chain_length": lastBundle,
721
"total_bundles": count,
722
"total_size_mb": float64(size) / 1024 / 1024,
···
728
})
729
}
730
731
-
// ====================
732
-
// PLC Export Handler
733
-
// ====================
734
735
func (s *Server) handlePLCExport(w http.ResponseWriter, r *http.Request) {
736
ctx := r.Context()
737
738
-
// Parse query parameters
739
-
countStr := r.URL.Query().Get("count")
740
-
afterStr := r.URL.Query().Get("after")
741
742
-
count := 1000 // Default
743
-
if countStr != "" {
744
-
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
745
-
count = c
746
-
if count > 10000 {
747
-
count = 10000 // Max limit
748
-
}
749
}
750
}
751
752
-
var afterTime time.Time
753
-
if afterStr != "" {
754
-
// Try multiple timestamp formats (from most specific to least)
755
-
formats := []string{
756
-
time.RFC3339Nano,
757
-
time.RFC3339,
758
-
"2006-01-02T15:04:05.000Z",
759
-
"2006-01-02T15:04:05",
760
-
"2006-01-02T15:04",
761
-
"2006-01-02",
762
}
763
764
-
var parsed time.Time
765
-
var parseErr error
766
-
parsed = time.Time{}
767
768
-
for _, format := range formats {
769
-
parsed, parseErr = time.Parse(format, afterStr)
770
-
if parseErr == nil {
771
-
afterTime = parsed
772
-
break
773
-
}
774
-
}
775
776
-
if parseErr != nil {
777
-
http.Error(w, fmt.Sprintf("Invalid after parameter: %v", parseErr), http.StatusBadRequest)
778
-
return
779
-
}
780
}
781
782
-
// Find starting bundle (FAST - single query)
783
-
startBundle := 1
784
-
if !afterTime.IsZero() {
785
-
foundBundle, err := s.db.GetBundleForTimestamp(ctx, afterTime)
786
-
if err != nil {
787
-
log.Error("Failed to find bundle for timestamp: %v", err)
788
-
// Fallback to bundle 1
789
-
} else {
790
-
startBundle = foundBundle
791
-
// Go back one bundle to catch boundary timestamps
792
-
if startBundle > 1 {
793
-
startBundle--
794
-
}
795
-
}
796
}
797
798
-
// Collect operations from bundles
799
var allOps []plc.PLCOperation
800
seenCIDs := make(map[string]bool)
801
802
-
// Load bundles sequentially until we have enough operations
803
lastBundle, _ := s.db.GetLastBundleNumber(ctx)
804
805
for bundleNum := startBundle; bundleNum <= lastBundle && len(allOps) < count; bundleNum++ {
806
-
bundlePath := filepath.Join(s.plcBundleDir, fmt.Sprintf("%06d.jsonl.zst", bundleNum))
807
-
808
-
ops, err := s.loadBundleOperations(bundlePath)
809
if err != nil {
810
log.Error("Warning: failed to load bundle %d: %v", bundleNum, err)
811
continue
812
}
813
814
-
// Filter operations
815
for _, op := range ops {
816
-
// Skip if STRICTLY BEFORE "after" timestamp
817
-
// Include operations AT or AFTER the timestamp
818
if !afterTime.IsZero() && op.CreatedAt.Before(afterTime) {
819
continue
820
}
821
822
-
// Skip duplicates (by CID)
823
if seenCIDs[op.CID] {
824
continue
825
}
···
833
}
834
}
835
836
-
// Set headers for JSONL response
837
-
w.Header().Set("Content-Type", "application/jsonl")
838
-
w.Header().Set("X-Operation-Count", strconv.Itoa(len(allOps)))
839
-
840
-
// Write JSONL response (newline-delimited JSON with trailing newline)
841
-
for _, op := range allOps {
842
-
// Use raw JSON if available
843
-
if len(op.RawJSON) > 0 {
844
-
w.Write(op.RawJSON)
845
-
} else {
846
-
// Fallback: marshal the operation
847
-
jsonData, err := json.Marshal(op)
848
-
if err != nil {
849
-
log.Error("Failed to marshal operation: %v", err)
850
-
continue
851
-
}
852
-
w.Write(jsonData)
853
-
}
854
-
855
-
// Always add newline after each operation (including the last)
856
-
w.Write([]byte("\n"))
857
-
}
858
}
859
860
-
// ====================
861
-
// Health Handler
862
-
// ====================
863
864
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
865
-
respondJSON(w, map[string]string{"status": "ok"})
866
}
867
868
-
// ====================
869
-
// Helper Functions
870
-
// ====================
871
-
872
-
// loadBundleOperations loads operations from a bundle file
873
-
func (s *Server) loadBundleOperations(path string) ([]plc.PLCOperation, error) {
874
-
decoder, err := zstd.NewReader(nil)
875
-
if err != nil {
876
-
return nil, err
877
-
}
878
-
defer decoder.Close()
879
-
880
-
compressedData, err := os.ReadFile(path)
881
-
if err != nil {
882
-
return nil, err
883
-
}
884
-
885
-
decompressed, err := decoder.DecodeAll(compressedData, nil)
886
-
if err != nil {
887
-
return nil, err
888
-
}
889
-
890
-
// Parse JSONL (newline-delimited JSON)
891
-
var operations []plc.PLCOperation
892
-
scanner := bufio.NewScanner(bytes.NewReader(decompressed))
893
-
894
-
lineNum := 0
895
-
for scanner.Scan() {
896
-
lineNum++
897
-
line := scanner.Bytes()
898
-
899
-
// Skip empty lines
900
-
if len(line) == 0 {
901
-
continue
902
-
}
903
-
904
-
var op plc.PLCOperation
905
-
if err := json.Unmarshal(line, &op); err != nil {
906
-
return nil, fmt.Errorf("failed to parse operation on line %d: %w", lineNum, err)
907
-
}
908
-
909
-
// CRITICAL: Store the original raw JSON bytes
910
-
op.RawJSON = make([]byte, len(line))
911
-
copy(op.RawJSON, line)
912
-
913
-
operations = append(operations, op)
914
-
}
915
-
916
-
if err := scanner.Err(); err != nil {
917
-
return nil, fmt.Errorf("error reading JSONL: %w", err)
918
-
}
919
920
-
return operations, nil
921
-
}
922
-
923
-
// computeRemoteOperationsHash computes hash for remote operations
924
-
func computeRemoteOperationsHash(ops []plc.PLCOperation) (string, error) {
925
var jsonlData []byte
926
-
for i, op := range ops {
927
-
if len(op.RawJSON) > 0 {
928
-
jsonlData = append(jsonlData, op.RawJSON...)
929
-
} else {
930
-
return "", fmt.Errorf("operation %d missing raw JSON data", i)
931
-
}
932
-
// Add newline ONLY between operations
933
jsonlData = append(jsonlData, '\n')
934
}
935
-
936
hash := sha256.Sum256(jsonlData)
937
-
return hex.EncodeToString(hash[:]), nil
938
-
}
939
-
940
-
// statusToString converts status int to string
941
-
func statusToString(status int) string {
942
-
switch status {
943
-
case storage.PDSStatusOnline: // Use PDSStatusOnline (alias)
944
-
return "online"
945
-
case storage.PDSStatusOffline: // Use PDSStatusOffline (alias)
946
-
return "offline"
947
-
default:
948
-
return "unknown"
949
-
}
950
-
}
951
-
952
-
// respondJSON writes JSON response
953
-
func respondJSON(w http.ResponseWriter, data interface{}) {
954
-
w.Header().Set("Content-Type", "application/json")
955
-
json.NewEncoder(w).Encode(data)
956
}
···
1
package api
2
3
import (
4
+
"context"
5
"crypto/sha256"
6
"encoding/hex"
7
"encoding/json"
···
16
"github.com/atscan/atscanner/internal/plc"
17
"github.com/atscan/atscanner/internal/storage"
18
"github.com/gorilla/mux"
19
)
20
21
+
// ===== RESPONSE HELPERS =====
22
+
23
+
type response struct {
24
+
w http.ResponseWriter
25
+
}
26
+
27
+
func newResponse(w http.ResponseWriter) *response {
28
+
return &response{w: w}
29
+
}
30
+
31
+
func (r *response) json(data interface{}) {
32
+
r.w.Header().Set("Content-Type", "application/json")
33
+
json.NewEncoder(r.w).Encode(data)
34
+
}
35
+
36
+
func (r *response) error(msg string, code int) {
37
+
http.Error(r.w, msg, code)
38
+
}
39
40
+
func (r *response) bundleHeaders(bundle *storage.PLCBundle) {
41
+
r.w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundle.BundleNumber))
42
+
r.w.Header().Set("X-Bundle-Hash", bundle.Hash)
43
+
r.w.Header().Set("X-Bundle-Compressed-Hash", bundle.CompressedHash)
44
+
r.w.Header().Set("X-Bundle-Start-Time", bundle.StartTime.Format(time.RFC3339Nano))
45
+
r.w.Header().Set("X-Bundle-End-Time", bundle.EndTime.Format(time.RFC3339Nano))
46
+
r.w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE))
47
+
r.w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(bundle.DIDs)))
48
+
}
49
50
+
// ===== REQUEST HELPERS =====
51
52
+
func getBundleNumber(r *http.Request) (int, error) {
53
+
vars := mux.Vars(r)
54
+
return strconv.Atoi(vars["number"])
55
+
}
56
57
+
func getQueryInt(r *http.Request, key string, defaultVal int) int {
58
+
if val := r.URL.Query().Get(key); val != "" {
59
+
if parsed, err := strconv.Atoi(val); err == nil {
60
+
return parsed
61
+
}
62
}
63
+
return defaultVal
64
+
}
65
66
+
func getQueryInt64(r *http.Request, key string, defaultVal int64) int64 {
67
+
if val := r.URL.Query().Get(key); val != "" {
68
+
if parsed, err := strconv.ParseInt(val, 10, 64); err == nil {
69
+
return parsed
70
}
71
}
72
+
return defaultVal
73
+
}
74
75
+
// ===== FORMATTING HELPERS =====
76
+
77
+
func formatBundleResponse(bundle *storage.PLCBundle) map[string]interface{} {
78
+
return map[string]interface{}{
79
+
"plc_bundle_number": bundle.BundleNumber,
80
+
"start_time": bundle.StartTime,
81
+
"end_time": bundle.EndTime,
82
+
"operation_count": plc.BUNDLE_SIZE,
83
+
"did_count": len(bundle.DIDs),
84
+
"hash": bundle.Hash,
85
+
"compressed_hash": bundle.CompressedHash,
86
+
"compressed_size": bundle.CompressedSize,
87
+
"prev_bundle_hash": bundle.PrevBundleHash,
88
+
"created_at": bundle.CreatedAt,
89
}
90
+
}
91
92
+
func formatEndpointResponse(ep *storage.Endpoint) map[string]interface{} {
93
+
return map[string]interface{}{
94
+
"id": ep.ID,
95
+
"endpoint_type": ep.EndpointType,
96
+
"endpoint": ep.Endpoint,
97
+
"discovered_at": ep.DiscoveredAt,
98
+
"last_checked": ep.LastChecked,
99
+
"status": statusToString(ep.Status),
100
+
"user_count": ep.UserCount,
101
}
102
+
}
103
104
+
func statusToString(status int) string {
105
+
switch status {
106
+
case storage.EndpointStatusOnline:
107
+
return "online"
108
+
case storage.EndpointStatusOffline:
109
+
return "offline"
110
+
default:
111
+
return "unknown"
112
+
}
113
+
}
114
+
115
+
// ===== ENDPOINT HANDLERS =====
116
+
117
+
func (s *Server) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
118
+
resp := newResponse(w)
119
+
120
+
filter := &storage.EndpointFilter{
121
+
Type: r.URL.Query().Get("type"),
122
+
Status: r.URL.Query().Get("status"),
123
+
MinUserCount: getQueryInt64(r, "min_user_count", 0),
124
+
Limit: getQueryInt(r, "limit", 0),
125
+
Offset: getQueryInt(r, "offset", 0),
126
+
}
127
+
128
+
endpoints, err := s.db.GetEndpoints(r.Context(), filter)
129
if err != nil {
130
+
resp.error(err.Error(), http.StatusInternalServerError)
131
return
132
}
133
134
response := make([]map[string]interface{}, len(endpoints))
135
for i, ep := range endpoints {
136
+
response[i] = formatEndpointResponse(ep)
137
}
138
139
+
resp.json(response)
140
}
141
142
func (s *Server) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
143
+
resp := newResponse(w)
144
vars := mux.Vars(r)
145
endpoint := vars["endpoint"]
146
endpointType := r.URL.Query().Get("type")
147
if endpointType == "" {
148
endpointType = "pds"
149
}
150
151
+
ep, err := s.db.GetEndpoint(r.Context(), endpoint, endpointType)
152
if err != nil {
153
+
resp.error("Endpoint not found", http.StatusNotFound)
154
return
155
}
156
157
+
scans, _ := s.db.GetEndpointScans(r.Context(), ep.ID, 10)
158
159
+
result := formatEndpointResponse(ep)
160
+
result["recent_scans"] = scans
161
162
+
resp.json(result)
163
}
164
165
func (s *Server) handleGetEndpointStats(w http.ResponseWriter, r *http.Request) {
166
+
resp := newResponse(w)
167
+
stats, err := s.db.GetEndpointStats(r.Context())
168
if err != nil {
169
+
resp.error(err.Error(), http.StatusInternalServerError)
170
return
171
}
172
+
resp.json(stats)
173
}
174
175
+
// ===== DID HANDLERS =====
176
177
func (s *Server) handleGetDID(w http.ResponseWriter, r *http.Request) {
178
+
resp := newResponse(w)
179
vars := mux.Vars(r)
180
did := vars["did"]
181
182
+
bundles, err := s.db.GetBundlesForDID(r.Context(), did)
183
if err != nil {
184
+
resp.error(err.Error(), http.StatusInternalServerError)
185
return
186
}
187
188
if len(bundles) == 0 {
189
+
resp.error("DID not found in bundles", http.StatusNotFound)
190
return
191
}
192
193
lastBundle := bundles[len(bundles)-1]
194
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), lastBundle.BundleNumber)
195
if err != nil {
196
+
resp.error(fmt.Sprintf("failed to load bundle: %v", err), http.StatusInternalServerError)
197
return
198
}
199
200
// Find latest operation for this DID
201
+
for i := len(ops) - 1; i >= 0; i-- {
202
+
if ops[i].DID == did {
203
+
resp.json(ops[i])
204
+
return
205
}
206
}
207
208
+
resp.error("DID operation not found", http.StatusNotFound)
209
}
210
211
func (s *Server) handleGetDIDHistory(w http.ResponseWriter, r *http.Request) {
212
+
resp := newResponse(w)
213
vars := mux.Vars(r)
214
did := vars["did"]
215
216
+
bundles, err := s.db.GetBundlesForDID(r.Context(), did)
217
if err != nil {
218
+
resp.error(err.Error(), http.StatusInternalServerError)
219
return
220
}
221
222
if len(bundles) == 0 {
223
+
resp.error("DID not found in bundles", http.StatusNotFound)
224
return
225
}
226
···
228
var currentOp *plc.PLCOperation
229
230
for _, bundle := range bundles {
231
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), bundle.BundleNumber)
232
if err != nil {
233
log.Error("Warning: failed to load bundle: %v", err)
234
continue
235
}
236
237
+
for _, op := range ops {
238
if op.DID == did {
239
entry := plc.DIDHistoryEntry{
240
Operation: op,
···
246
}
247
}
248
249
+
resp.json(plc.DIDHistory{
250
DID: did,
251
Current: currentOp,
252
Operations: allOperations,
253
+
})
254
}
255
256
+
// ===== PLC BUNDLE HANDLERS =====
257
258
func (s *Server) handleGetPLCBundle(w http.ResponseWriter, r *http.Request) {
259
+
resp := newResponse(w)
260
261
+
bundleNum, err := getBundleNumber(r)
262
if err != nil {
263
+
resp.error("invalid bundle number", http.StatusBadRequest)
264
return
265
}
266
267
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
268
if err != nil {
269
+
resp.error("bundle not found", http.StatusNotFound)
270
return
271
}
272
273
+
resp.json(formatBundleResponse(bundle))
274
}
275
276
func (s *Server) handleGetPLCBundleDIDs(w http.ResponseWriter, r *http.Request) {
277
+
resp := newResponse(w)
278
279
+
bundleNum, err := getBundleNumber(r)
280
if err != nil {
281
+
resp.error("invalid bundle number", http.StatusBadRequest)
282
return
283
}
284
285
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
286
if err != nil {
287
+
resp.error("bundle not found", http.StatusNotFound)
288
return
289
}
290
291
+
resp.json(map[string]interface{}{
292
"plc_bundle_number": bundle.BundleNumber,
293
"did_count": len(bundle.DIDs),
294
"dids": bundle.DIDs,
···
296
}
297
298
func (s *Server) handleDownloadPLCBundle(w http.ResponseWriter, r *http.Request) {
299
+
resp := newResponse(w)
300
301
+
bundleNum, err := getBundleNumber(r)
302
if err != nil {
303
+
resp.error("invalid bundle number", http.StatusBadRequest)
304
return
305
}
306
307
+
compressed := r.URL.Query().Get("compressed") != "false"
308
309
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum)
310
if err != nil {
311
+
resp.error("bundle not found", http.StatusNotFound)
312
return
313
}
314
315
+
resp.bundleHeaders(bundle)
316
+
317
+
if compressed {
318
+
s.serveCompressedBundle(w, r, bundle)
319
+
} else {
320
+
s.serveUncompressedBundle(w, r, bundle)
321
+
}
322
+
}
323
324
+
func (s *Server) serveCompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) {
325
+
resp := newResponse(w)
326
+
path := bundle.GetFilePath(s.plcBundleDir)
327
+
328
+
file, err := os.Open(path)
329
if err != nil {
330
+
resp.error("bundle file not found on disk", http.StatusNotFound)
331
return
332
}
333
+
defer file.Close()
334
335
+
fileInfo, _ := file.Stat()
336
337
+
w.Header().Set("Content-Type", "application/zstd")
338
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundle.BundleNumber))
339
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
340
+
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size()))
341
342
+
http.ServeContent(w, r, filepath.Base(path), bundle.CreatedAt, file)
343
+
}
344
345
+
func (s *Server) serveUncompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) {
346
+
resp := newResponse(w)
347
348
+
ops, err := s.bundleManager.LoadBundleOperations(r.Context(), bundle.BundleNumber)
349
+
if err != nil {
350
+
resp.error(fmt.Sprintf("error loading bundle: %v", err), http.StatusInternalServerError)
351
+
return
352
+
}
353
354
+
// Serialize to JSONL
355
+
var buf []byte
356
+
for _, op := range ops {
357
+
buf = append(buf, op.RawJSON...)
358
+
buf = append(buf, '\n')
359
+
}
360
361
+
fileInfo, _ := os.Stat(bundle.GetFilePath(s.plcBundleDir))
362
+
compressedSize := int64(0)
363
+
if fileInfo != nil {
364
+
compressedSize = fileInfo.Size()
365
+
}
366
367
+
w.Header().Set("Content-Type", "application/jsonl")
368
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundle.BundleNumber))
369
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(buf)))
370
+
w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", compressedSize))
371
+
w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", len(buf)))
372
+
if compressedSize > 0 {
373
+
w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(len(buf))/float64(compressedSize)))
374
}
375
+
376
+
w.WriteHeader(http.StatusOK)
377
+
w.Write(buf)
378
}
379
380
func (s *Server) handleGetPLCBundles(w http.ResponseWriter, r *http.Request) {
381
+
resp := newResponse(w)
382
+
limit := getQueryInt(r, "limit", 50)
383
384
+
bundles, err := s.db.GetBundles(r.Context(), limit)
385
if err != nil {
386
+
resp.error(err.Error(), http.StatusInternalServerError)
387
return
388
}
389
390
response := make([]map[string]interface{}, len(bundles))
391
for i, bundle := range bundles {
392
+
response[i] = formatBundleResponse(bundle)
393
}
394
395
+
resp.json(response)
396
}
397
398
func (s *Server) handleGetPLCBundleStats(w http.ResponseWriter, r *http.Request) {
399
+
resp := newResponse(w)
400
401
+
count, size, err := s.db.GetBundleStats(r.Context())
402
if err != nil {
403
+
resp.error(err.Error(), http.StatusInternalServerError)
404
return
405
}
406
407
+
resp.json(map[string]interface{}{
408
"plc_bundle_count": count,
409
"total_size": size,
410
"total_size_mb": float64(size) / 1024 / 1024,
411
})
412
}
413
414
+
// ===== MEMPOOL HANDLERS =====
415
416
func (s *Server) handleGetMempoolStats(w http.ResponseWriter, r *http.Request) {
417
+
resp := newResponse(w)
418
ctx := r.Context()
419
420
count, err := s.db.GetMempoolCount(ctx)
421
if err != nil {
422
+
resp.error(err.Error(), http.StatusInternalServerError)
423
return
424
}
425
426
+
result := map[string]interface{}{
427
"operation_count": count,
428
"can_create_bundle": count >= plc.BUNDLE_SIZE,
429
}
430
431
if count > 0 {
432
+
if firstOp, err := s.db.GetFirstMempoolOperation(ctx); err == nil && firstOp != nil {
433
+
result["mempool_start_time"] = firstOp.CreatedAt
434
435
if count < plc.BUNDLE_SIZE {
436
+
if lastOp, err := s.db.GetLastMempoolOperation(ctx); err == nil && lastOp != nil {
437
timeSpan := lastOp.CreatedAt.Sub(firstOp.CreatedAt).Seconds()
438
if timeSpan > 0 {
439
opsPerSecond := float64(count) / timeSpan
440
if opsPerSecond > 0 {
441
remainingOps := plc.BUNDLE_SIZE - count
442
secondsNeeded := float64(remainingOps) / opsPerSecond
443
+
result["estimated_next_bundle_time"] = time.Now().Add(time.Duration(secondsNeeded) * time.Second)
444
+
result["operations_needed"] = remainingOps
445
+
result["current_rate_per_second"] = opsPerSecond
446
}
447
}
448
}
449
} else {
450
+
result["estimated_next_bundle_time"] = time.Now()
451
+
result["operations_needed"] = 0
452
}
453
}
454
} else {
455
+
result["mempool_start_time"] = nil
456
+
result["estimated_next_bundle_time"] = nil
457
}
458
459
+
resp.json(result)
460
}
461
462
+
// ===== PLC METRICS HANDLERS =====
463
464
func (s *Server) handleGetPLCMetrics(w http.ResponseWriter, r *http.Request) {
465
+
resp := newResponse(w)
466
+
limit := getQueryInt(r, "limit", 10)
467
468
+
metrics, err := s.db.GetPLCMetrics(r.Context(), limit)
469
if err != nil {
470
+
resp.error(err.Error(), http.StatusInternalServerError)
471
return
472
}
473
474
+
resp.json(metrics)
475
}
476
477
+
// ===== VERIFICATION HANDLERS =====
478
479
func (s *Server) handleVerifyPLCBundle(w http.ResponseWriter, r *http.Request) {
480
+
resp := newResponse(w)
481
vars := mux.Vars(r)
482
483
+
bundleNumber, err := strconv.Atoi(vars["bundleNumber"])
484
if err != nil {
485
+
resp.error("Invalid bundle number", http.StatusBadRequest)
486
return
487
}
488
489
+
bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNumber)
490
if err != nil {
491
+
resp.error("Bundle not found", http.StatusNotFound)
492
return
493
}
494
495
+
// Fetch from PLC and verify
496
+
remoteOps, prevCIDs, err := s.fetchRemoteBundleOps(r.Context(), bundleNumber)
497
+
if err != nil {
498
+
resp.error(fmt.Sprintf("Failed to fetch from PLC directory: %v", err), http.StatusInternalServerError)
499
+
return
500
+
}
501
+
502
+
remoteHash := computeOperationsHash(remoteOps)
503
+
verified := bundle.Hash == remoteHash
504
+
505
+
resp.json(map[string]interface{}{
506
+
"bundle_number": bundleNumber,
507
+
"verified": verified,
508
+
"local_hash": bundle.Hash,
509
+
"remote_hash": remoteHash,
510
+
"local_op_count": plc.BUNDLE_SIZE,
511
+
"remote_op_count": len(remoteOps),
512
+
"boundary_cids_used": len(prevCIDs),
513
+
})
514
+
}
515
+
516
+
func (s *Server) fetchRemoteBundleOps(ctx context.Context, bundleNum int) ([]plc.PLCOperation, map[string]bool, error) {
517
var after string
518
var prevBoundaryCIDs map[string]bool
519
520
+
if bundleNum > 1 {
521
+
prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNum-1)
522
if err != nil {
523
+
return nil, nil, fmt.Errorf("failed to get previous bundle: %w", err)
524
}
525
526
after = prevBundle.EndTime.Format("2006-01-02T15:04:05.000Z")
527
528
if len(prevBundle.BoundaryCIDs) > 0 {
529
prevBoundaryCIDs = make(map[string]bool)
530
for _, cid := range prevBundle.BoundaryCIDs {
···
533
}
534
}
535
536
var allRemoteOps []plc.PLCOperation
537
seenCIDs := make(map[string]bool)
538
539
for cid := range prevBoundaryCIDs {
540
seenCIDs[cid] = true
541
}
542
543
currentAfter := after
544
+
maxFetches := 20
545
546
for fetchNum := 0; fetchNum < maxFetches && len(allRemoteOps) < plc.BUNDLE_SIZE; fetchNum++ {
547
batch, err := s.plcClient.Export(ctx, plc.ExportOptions{
548
Count: 1000,
549
After: currentAfter,
550
})
551
+
if err != nil || len(batch) == 0 {
552
break
553
}
554
555
for _, op := range batch {
556
if !seenCIDs[op.CID] {
557
seenCIDs[op.CID] = true
···
562
}
563
}
564
565
if len(batch) > 0 {
566
lastOp := batch[len(batch)-1]
567
currentAfter = lastOp.CreatedAt.Format("2006-01-02T15:04:05.000Z")
568
}
569
570
if len(batch) < 1000 {
571
break
572
}
573
}
574
575
if len(allRemoteOps) > plc.BUNDLE_SIZE {
576
allRemoteOps = allRemoteOps[:plc.BUNDLE_SIZE]
577
}
578
579
+
return allRemoteOps, prevBoundaryCIDs, nil
580
}
581
582
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
583
+
resp := newResponse(w)
584
ctx := r.Context()
585
586
lastBundle, err := s.db.GetLastBundleNumber(ctx)
587
if err != nil {
588
+
resp.error(err.Error(), http.StatusInternalServerError)
589
return
590
}
591
592
if lastBundle == 0 {
593
+
resp.json(map[string]interface{}{
594
"status": "empty",
595
"message": "No bundles to verify",
596
})
597
return
598
}
599
600
valid := true
601
var brokenAt int
602
var errorMsg string
···
610
break
611
}
612
613
if i > 1 {
614
prevBundle, err := s.db.GetBundleByNumber(ctx, i-1)
615
if err != nil {
···
628
}
629
}
630
631
+
result := map[string]interface{}{
632
"chain_length": lastBundle,
633
"valid": valid,
634
}
635
636
if !valid {
637
+
result["broken_at"] = brokenAt
638
+
result["error"] = errorMsg
639
}
640
641
+
resp.json(result)
642
}
643
644
func (s *Server) handleGetChainInfo(w http.ResponseWriter, r *http.Request) {
645
+
resp := newResponse(w)
646
ctx := r.Context()
647
648
lastBundle, err := s.db.GetLastBundleNumber(ctx)
649
if err != nil {
650
+
resp.error(err.Error(), http.StatusInternalServerError)
651
return
652
}
653
654
if lastBundle == 0 {
655
+
resp.json(map[string]interface{}{
656
"chain_length": 0,
657
"status": "empty",
658
})
···
661
662
firstBundle, _ := s.db.GetBundleByNumber(ctx, 1)
663
lastBundleData, _ := s.db.GetBundleByNumber(ctx, lastBundle)
664
count, size, _ := s.db.GetBundleStats(ctx)
665
666
+
resp.json(map[string]interface{}{
667
"chain_length": lastBundle,
668
"total_bundles": count,
669
"total_size_mb": float64(size) / 1024 / 1024,
···
675
})
676
}
677
678
+
// ===== PLC EXPORT HANDLER =====
679
680
func (s *Server) handlePLCExport(w http.ResponseWriter, r *http.Request) {
681
+
resp := newResponse(w)
682
ctx := r.Context()
683
684
+
count := getQueryInt(r, "count", 1000)
685
+
if count > 10000 {
686
+
count = 10000
687
+
}
688
689
+
afterTime, err := parseAfterParam(r.URL.Query().Get("after"))
690
+
if err != nil {
691
+
resp.error(fmt.Sprintf("Invalid after parameter: %v", err), http.StatusBadRequest)
692
+
return
693
+
}
694
+
695
+
startBundle := s.findStartBundle(ctx, afterTime)
696
+
ops := s.collectOperations(ctx, startBundle, afterTime, count)
697
+
698
+
w.Header().Set("Content-Type", "application/jsonl")
699
+
w.Header().Set("X-Operation-Count", strconv.Itoa(len(ops)))
700
+
701
+
for _, op := range ops {
702
+
if len(op.RawJSON) > 0 {
703
+
w.Write(op.RawJSON)
704
+
} else {
705
+
jsonData, _ := json.Marshal(op)
706
+
w.Write(jsonData)
707
}
708
+
w.Write([]byte("\n"))
709
}
710
+
}
711
712
+
func parseAfterParam(afterStr string) (time.Time, error) {
713
+
if afterStr == "" {
714
+
return time.Time{}, nil
715
+
}
716
+
717
+
formats := []string{
718
+
time.RFC3339Nano,
719
+
time.RFC3339,
720
+
"2006-01-02T15:04:05.000Z",
721
+
"2006-01-02T15:04:05",
722
+
"2006-01-02T15:04",
723
+
"2006-01-02",
724
+
}
725
+
726
+
for _, format := range formats {
727
+
if parsed, err := time.Parse(format, afterStr); err == nil {
728
+
return parsed, nil
729
}
730
+
}
731
732
+
return time.Time{}, fmt.Errorf("invalid timestamp format")
733
+
}
734
735
+
func (s *Server) findStartBundle(ctx context.Context, afterTime time.Time) int {
736
+
if afterTime.IsZero() {
737
+
return 1
738
+
}
739
740
+
foundBundle, err := s.db.GetBundleForTimestamp(ctx, afterTime)
741
+
if err != nil {
742
+
return 1
743
}
744
745
+
if foundBundle > 1 {
746
+
return foundBundle - 1
747
}
748
+
return foundBundle
749
+
}
750
751
+
func (s *Server) collectOperations(ctx context.Context, startBundle int, afterTime time.Time, count int) []plc.PLCOperation {
752
var allOps []plc.PLCOperation
753
seenCIDs := make(map[string]bool)
754
755
lastBundle, _ := s.db.GetLastBundleNumber(ctx)
756
757
for bundleNum := startBundle; bundleNum <= lastBundle && len(allOps) < count; bundleNum++ {
758
+
ops, err := s.bundleManager.LoadBundleOperations(ctx, bundleNum)
759
if err != nil {
760
log.Error("Warning: failed to load bundle %d: %v", bundleNum, err)
761
continue
762
}
763
764
for _, op := range ops {
765
if !afterTime.IsZero() && op.CreatedAt.Before(afterTime) {
766
continue
767
}
768
769
if seenCIDs[op.CID] {
770
continue
771
}
···
779
}
780
}
781
782
+
return allOps
783
}
784
785
+
// ===== HEALTH HANDLER =====
786
787
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
788
+
newResponse(w).json(map[string]string{"status": "ok"})
789
}
790
791
+
// ===== UTILITY FUNCTIONS =====
792
793
+
func computeOperationsHash(ops []plc.PLCOperation) string {
794
var jsonlData []byte
795
+
for _, op := range ops {
796
+
jsonlData = append(jsonlData, op.RawJSON...)
797
jsonlData = append(jsonlData, '\n')
798
}
799
hash := sha256.Sum256(jsonlData)
800
+
return hex.EncodeToString(hash[:])
801
}
+13
-9
internal/api/server.go
+13
-9
internal/api/server.go
···
15
)
16
17
type Server struct {
18
-
router *mux.Router
19
-
server *http.Server
20
-
db storage.Database
21
-
plcClient *plc.Client
22
-
plcBundleDir string // NEW: Store cache dir
23
}
24
25
func NewServer(db storage.Database, apiCfg config.APIConfig, plcCfg config.PLCConfig) *Server {
26
s := &Server{
27
-
router: mux.NewRouter(),
28
-
db: db,
29
-
plcClient: plc.NewClient(plcCfg.DirectoryURL),
30
-
plcBundleDir: plcCfg.BundleDir, // NEW
31
}
32
33
s.setupRoutes()
···
15
)
16
17
type Server struct {
18
+
router *mux.Router
19
+
server *http.Server
20
+
db storage.Database
21
+
plcClient *plc.Client
22
+
plcBundleDir string
23
+
bundleManager *plc.BundleManager
24
}
25
26
func NewServer(db storage.Database, apiCfg config.APIConfig, plcCfg config.PLCConfig) *Server {
27
+
bundleManager, _ := plc.NewBundleManager(plcCfg.BundleDir, plcCfg.UseCache, db)
28
+
29
s := &Server{
30
+
router: mux.NewRouter(),
31
+
db: db,
32
+
plcClient: plc.NewClient(plcCfg.DirectoryURL),
33
+
plcBundleDir: plcCfg.BundleDir,
34
+
bundleManager: bundleManager,
35
}
36
37
s.setupRoutes()
+19
internal/plc/bundle.go
+19
internal/plc/bundle.go
···
576
577
return operations[startIdx:]
578
}
579
+
580
+
// LoadBundleOperations is a public method for external access (e.g., API handlers)
581
+
func (bm *BundleManager) LoadBundleOperations(ctx context.Context, bundleNum int) ([]PLCOperation, error) {
582
+
if !bm.enabled {
583
+
return nil, fmt.Errorf("bundle manager disabled")
584
+
}
585
+
586
+
bf := bm.newBundleFile(bundleNum)
587
+
588
+
if !bf.exists() {
589
+
return nil, fmt.Errorf("bundle %06d not found", bundleNum)
590
+
}
591
+
592
+
if err := bm.load(bf); err != nil {
593
+
return nil, err
594
+
}
595
+
596
+
return bf.operations, nil
597
+
}