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