From 9d03e1b08a9befedfbbb6614c37bd91993e4732a Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Apr 2026 20:02:57 +0200 Subject: [PATCH] first up --- go.mod | 19 ++++ internal/db/db.go | 75 ++++++++++++++++ internal/server/server.go | 125 ++++++++++++++++++++++++++ internal/settings/settings.go | 161 ++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 go.mod create mode 100644 internal/db/db.go create mode 100644 internal/server/server.go create mode 100644 internal/settings/settings.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dda270a --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.rouggy.com/rouggy/stockradar + +go 1.25.7 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.49.1 // indirect +) diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..dc8dfc5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,75 @@ +package db + +import ( + "database/sql" + + _ "modernc.org/sqlite" // à la place de go-sqlite3 +) + +type DB struct { + *sql.DB +} + +func Init(path string) (*DB, error) { + sqldb, err := sql.Open("sqlite", path) // "sqlite" au lieu de "sqlite3" + if err != nil { + return nil, err + } + + if err := sqldb.Ping(); err != nil { + return nil, err + } + + database := &DB{sqldb} + if err := database.migrate(); err != nil { + return nil, err + } + + return database, nil +} + +func (db *DB) migrate() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + encrypted INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT NOT NULL UNIQUE, + name TEXT, + sector TEXT, + exchange TEXT, + active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS instruments ( + instrument_id INTEGER PRIMARY KEY, + ticker TEXT NOT NULL, + name TEXT, + sector_id INTEGER, + exchange_id INTEGER, + asset_class_id INTEGER, + synced_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT, + headline TEXT NOT NULL, + source TEXT, + url TEXT, + sentiment TEXT, + published_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + } + + for _, q := range queries { + if _, err := db.Exec(q); err != nil { + return err + } + } + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..23d4d9a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,125 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "git.rouggy.com/rouggy/stockradar/internal/db" + "git.rouggy.com/rouggy/stockradar/internal/settings" + "github.com/gorilla/mux" +) + +type Server struct { + db *db.DB + port string + router *mux.Router + settings *settings.Settings +} + +func New(database *db.DB, port string) (*Server, error) { + svc, err := settings.New(database) + if err != nil { + return nil, err + } + + s := &Server{ + db: database, + port: port, + router: mux.NewRouter(), + settings: svc, + } + s.setupRoutes() + return s, nil +} + +func (s *Server) setupRoutes() { + api := s.router.PathPrefix("/api").Subrouter() + api.HandleFunc("/health", s.handleHealth).Methods("GET") + + // Settings + api.HandleFunc("/settings", s.handleGetSettings).Methods("GET") + api.HandleFunc("/settings", s.handleSaveSettings).Methods("POST") + api.HandleFunc("/settings/test/{provider}", s.handleTestKey).Methods("GET") + + s.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "StockRadar API running") + }) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","port":"%s"}`, s.port) +} + +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + all, err := s.settings.GetAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(all) +} + +func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Clés API → chiffrées, reste → plain text + encryptedKeys := map[string]bool{ + "etoro_api_key": true, + "finnhub_api_key": true, + "alphavantage_key": true, + } + + for key, val := range body { + value, ok := val.(string) + if !ok { + continue + } + encrypted := encryptedKeys[key] + if err := s.settings.Set(key, value, encrypted); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"saved"}`) +} + +func (s *Server) handleTestKey(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + provider := vars["provider"] + + keyMap := map[string]string{ + "etoro": "etoro_api_key", + "finnhub": "finnhub_api_key", + "alphavantage": "alphavantage_key", + } + + keyName, ok := keyMap[provider] + if !ok { + http.Error(w, "unknown provider", http.StatusBadRequest) + return + } + + if !s.settings.HasKey(keyName) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"error","message":"API key not configured"}`) + return + } + + // Pour l'instant on vérifie juste que la clé existe + // On branchera le vrai ping API plus tard + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","provider":"%s"}`, provider) +} + +func (s *Server) Start() error { + return http.ListenAndServe(":"+s.port, s.router) +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..38fe584 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,161 @@ +package settings + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" + + "git.rouggy.com/rouggy/stockradar/internal/db" +) + +type Settings struct { + db *db.DB + masterKey []byte +} + +func New(database *db.DB) (*Settings, error) { + key := os.Getenv("MASTER_KEY") + if key == "" { + return nil, errors.New("MASTER_KEY not set in environment") + } + // AES-256 nécessite 32 bytes + keyBytes := make([]byte, 32) + copy(keyBytes, []byte(key)) + + return &Settings{ + db: database, + masterKey: keyBytes, + }, nil +} + +// Set stocke une valeur, chiffrée si encrypted=true +func (s *Settings) Set(key, value string, encrypted bool) error { + stored := value + if encrypted { + var err error + stored, err = s.encrypt(value) + if err != nil { + return err + } + } + + enc := 0 + if encrypted { + enc = 1 + } + + _, err := s.db.Exec(` + INSERT INTO settings (key, value, encrypted, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + encrypted = excluded.encrypted, + updated_at = CURRENT_TIMESTAMP + `, key, stored, enc) + return err +} + +// Get récupère une valeur, la déchiffre si nécessaire +func (s *Settings) Get(key string) (string, error) { + var value string + var encrypted int + + err := s.db.QueryRow( + `SELECT value, encrypted FROM settings WHERE key = ?`, key, + ).Scan(&value, &encrypted) + if err != nil { + return "", err + } + + if encrypted == 1 { + return s.decrypt(value) + } + return value, nil +} + +// GetAll retourne tous les settings (valeurs sensibles masquées) +func (s *Settings) GetAll() (map[string]interface{}, error) { + rows, err := s.db.Query(`SELECT key, value, encrypted FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]interface{}) + for rows.Next() { + var key, value string + var encrypted int + if err := rows.Scan(&key, &value, &encrypted); err != nil { + return nil, err + } + if encrypted == 1 { + result[key] = "********" // on ne renvoie jamais les clés API en clair + } else { + result[key] = value + } + } + return result, nil +} + +// HasKey vérifie si une clé API est configurée sans la déchiffrer +func (s *Settings) HasKey(key string) bool { + var count int + s.db.QueryRow( + `SELECT COUNT(*) FROM settings WHERE key = ? AND value != ''`, key, + ).Scan(&count) + return count > 0 +} + +func (s *Settings) encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(s.masterKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (s *Settings) decrypt(encoded string) (string, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(s.masterKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +}