handle-neo4j.go raw
1 package app
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "time"
8
9 "next.orly.dev/pkg/lol/chk"
10 "next.orly.dev/pkg/lol/log"
11
12 "next.orly.dev/pkg/nostr/httpauth"
13 "next.orly.dev/pkg/acl"
14 "next.orly.dev/pkg/interfaces/store"
15 )
16
17 // Neo4jConfigResponse is the public response for GET /api/neo4j/config.
18 // No authentication required — used by the UI to decide whether to show the Neo4j tab.
19 type Neo4jConfigResponse struct {
20 DBType string `json:"db_type"`
21 }
22
23 // handleNeo4jConfig returns basic Neo4j configuration status.
24 // No authentication required — the UI uses this to decide tab visibility.
25 func (s *Server) handleNeo4jConfig(w http.ResponseWriter, r *http.Request) {
26 if r.Method != http.MethodGet {
27 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
28 return
29 }
30
31 w.Header().Set("Content-Type", "application/json")
32 json.NewEncoder(w).Encode(Neo4jConfigResponse{
33 DBType: s.Config.DBType,
34 })
35 }
36
37 // cypherRequest is the JSON body for POST /api/neo4j/cypher.
38 type cypherRequest struct {
39 Query string `json:"query"`
40 Params map[string]any `json:"params"`
41 Timeout int `json:"timeout"`
42 }
43
44 const (
45 cypherDefaultTimeoutSec = 30
46 cypherMaxTimeoutSec = 120
47 )
48
49 // handleNeo4jCypher handles POST /api/neo4j/cypher — NIP-98 owner-gated read-only
50 // Cypher query proxy. In split IPC mode this flows through gRPC to the database
51 // process, which holds the actual Neo4j driver connection.
52 func (s *Server) handleNeo4jCypher(w http.ResponseWriter, r *http.Request) {
53 if r.Method != http.MethodPost {
54 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
55 return
56 }
57
58 if !s.Config.Neo4jCypherEnabled {
59 http.Error(w, "Cypher endpoint disabled", http.StatusNotFound)
60 return
61 }
62
63 // NIP-98 authentication
64 valid, pubkey, err := httpauth.CheckAuth(r)
65 if chk.E(err) || !valid {
66 errorMsg := "NIP-98 authentication required"
67 if err != nil {
68 errorMsg = err.Error()
69 }
70 http.Error(w, errorMsg, http.StatusUnauthorized)
71 return
72 }
73
74 // Owner-only
75 accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
76 if accessLevel != "owner" {
77 http.Error(w, "Owner permission required", http.StatusForbidden)
78 return
79 }
80
81 // Check database supports Cypher
82 executor, ok := s.DB.(store.CypherExecutor)
83 if !ok {
84 http.Error(w, "Database backend does not support Cypher queries", http.StatusServiceUnavailable)
85 return
86 }
87
88 // Parse request body
89 var req cypherRequest
90 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
91 http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
92 return
93 }
94 if req.Query == "" {
95 http.Error(w, "Missing 'query' field", http.StatusBadRequest)
96 return
97 }
98
99 // Determine timeout
100 timeoutSec := s.Config.Neo4jCypherTimeoutSec
101 if timeoutSec <= 0 {
102 timeoutSec = cypherDefaultTimeoutSec
103 }
104 if req.Timeout > 0 {
105 timeoutSec = req.Timeout
106 }
107 if timeoutSec > cypherMaxTimeoutSec {
108 timeoutSec = cypherMaxTimeoutSec
109 }
110
111 ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSec)*time.Second)
112 defer cancel()
113
114 records, err := executor.ExecuteCypherRead(ctx, req.Query, req.Params)
115 if err != nil {
116 log.W.F("cypher query failed: %v", err)
117 http.Error(w, "Cypher query failed: "+err.Error(), http.StatusInternalServerError)
118 return
119 }
120
121 // Enforce max rows
122 maxRows := s.Config.Neo4jCypherMaxRows
123 if maxRows > 0 && len(records) > maxRows {
124 records = records[:maxRows]
125 }
126
127 w.Header().Set("Content-Type", "application/json")
128 json.NewEncoder(w).Encode(records)
129 }
130