678504b123
App service que expone las bases de datos SQLite del registry como endpoints HTTP. Solo queries SELECT/PRAGMA, apertura read-only (?mode=ro), timeout 5s, auto-discovery de operations.db, busqueda FTS5 directa, CORS habilitado. Puerto default 8484. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
191 lines
4.3 KiB
Go
191 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// DBEntry represents a registered database.
|
|
type DBEntry struct {
|
|
Alias string // "registry" or "ops:app_name"
|
|
Path string // absolute path to .db file
|
|
Kind string // "registry" or "operations"
|
|
}
|
|
|
|
// DBPool manages read-only connections to registered databases.
|
|
type DBPool struct {
|
|
mu sync.RWMutex
|
|
entries map[string]DBEntry
|
|
conns map[string]*sql.DB
|
|
}
|
|
|
|
func NewDBPool() *DBPool {
|
|
return &DBPool{
|
|
entries: make(map[string]DBEntry),
|
|
conns: make(map[string]*sql.DB),
|
|
}
|
|
}
|
|
|
|
// Register adds a database entry. Does not open the connection until first use.
|
|
func (p *DBPool) Register(entry DBEntry) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.entries[entry.Alias] = entry
|
|
}
|
|
|
|
// Get returns a read-only connection to the named database.
|
|
func (p *DBPool) Get(alias string) (*sql.DB, error) {
|
|
p.mu.RLock()
|
|
if db, ok := p.conns[alias]; ok {
|
|
p.mu.RUnlock()
|
|
return db, nil
|
|
}
|
|
p.mu.RUnlock()
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Double-check after acquiring write lock.
|
|
if db, ok := p.conns[alias]; ok {
|
|
return db, nil
|
|
}
|
|
|
|
entry, ok := p.entries[alias]
|
|
if !ok {
|
|
return nil, fmt.Errorf("database %q not found", alias)
|
|
}
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=ro", entry.Path)
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening %s: %w", alias, err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("pinging %s: %w", alias, err)
|
|
}
|
|
db.SetMaxOpenConns(4)
|
|
p.conns[alias] = db
|
|
return db, nil
|
|
}
|
|
|
|
// List returns all registered database entries.
|
|
func (p *DBPool) List() []DBEntry {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]DBEntry, 0, len(p.entries))
|
|
for _, e := range p.entries {
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Close closes all open connections.
|
|
func (p *DBPool) Close() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for _, db := range p.conns {
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
// Config holds the server configuration.
|
|
type Config struct {
|
|
Bind string // address to bind (default "127.0.0.1:8484")
|
|
RegistryDir string // root of fn_registry (where registry.db lives)
|
|
}
|
|
|
|
// DiscoverDatabases finds registry.db and all operations.db files.
|
|
func DiscoverDatabases(root string) []DBEntry {
|
|
var entries []DBEntry
|
|
|
|
// registry.db
|
|
regPath := filepath.Join(root, "registry.db")
|
|
if _, err := os.Stat(regPath); err == nil {
|
|
entries = append(entries, DBEntry{
|
|
Alias: "registry",
|
|
Path: regPath,
|
|
Kind: "registry",
|
|
})
|
|
}
|
|
|
|
// apps/*/operations.db
|
|
appsDir := filepath.Join(root, "apps")
|
|
dirEntries, err := os.ReadDir(appsDir)
|
|
if err != nil {
|
|
return entries
|
|
}
|
|
for _, d := range dirEntries {
|
|
if !d.IsDir() {
|
|
continue
|
|
}
|
|
opsPath := filepath.Join(appsDir, d.Name(), "operations.db")
|
|
if _, err := os.Stat(opsPath); err == nil {
|
|
entries = append(entries, DBEntry{
|
|
Alias: "ops:" + d.Name(),
|
|
Path: opsPath,
|
|
Kind: "operations",
|
|
})
|
|
}
|
|
}
|
|
|
|
// projects/*/apps/*/operations.db
|
|
projectsDir := filepath.Join(root, "projects")
|
|
projEntries, err := os.ReadDir(projectsDir)
|
|
if err != nil {
|
|
return entries
|
|
}
|
|
for _, p := range projEntries {
|
|
if !p.IsDir() {
|
|
continue
|
|
}
|
|
projAppsDir := filepath.Join(projectsDir, p.Name(), "apps")
|
|
projAppEntries, err := os.ReadDir(projAppsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, a := range projAppEntries {
|
|
if !a.IsDir() {
|
|
continue
|
|
}
|
|
opsPath := filepath.Join(projAppsDir, a.Name(), "operations.db")
|
|
if _, err := os.Stat(opsPath); err == nil {
|
|
entries = append(entries, DBEntry{
|
|
Alias: "ops:" + a.Name(),
|
|
Path: opsPath,
|
|
Kind: "operations",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// ValidateQuery checks that the SQL is a read-only statement (SELECT or PRAGMA).
|
|
func ValidateQuery(sql string) error {
|
|
trimmed := strings.TrimSpace(sql)
|
|
upper := strings.ToUpper(trimmed)
|
|
|
|
// Remove leading comments
|
|
for strings.HasPrefix(upper, "--") {
|
|
idx := strings.Index(upper, "\n")
|
|
if idx < 0 {
|
|
return fmt.Errorf("query contains only comments")
|
|
}
|
|
upper = strings.TrimSpace(upper[idx+1:])
|
|
}
|
|
|
|
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "PRAGMA") ||
|
|
strings.HasPrefix(upper, "WITH") || strings.HasPrefix(upper, "EXPLAIN") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("only SELECT, PRAGMA, WITH, and EXPLAIN queries are allowed")
|
|
}
|