feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version
This commit is contained in:
@@ -61,9 +61,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure the demo watchlist exists even without a seed bundle.
|
||||
// Ensure the demo account covers the "semua" default: every ticker with
|
||||
// stored data (fallback: config watchlist).
|
||||
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
|
||||
for _, t := range cfg.Watchlist {
|
||||
seeds, _ := db.AllTickers()
|
||||
if len(seeds) == 0 {
|
||||
seeds = cfg.Watchlist
|
||||
}
|
||||
for _, t := range seeds {
|
||||
_ = db.AddWatch(cfg.DemoUserKey, t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.T) {
|
||||
rec := do(s, "GET", "/api/briefing/today", nil)
|
||||
if rec.Code == http.StatusNotFound {
|
||||
// No briefing yet: run the routine via engine path instead.
|
||||
rows, _ := s.DB.ListRoutines("demo")
|
||||
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||
rows, _ := s.DB.ListRoutines(u.UserKey)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("seed has no routines")
|
||||
}
|
||||
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
|
||||
// Screener returns a ranked list with per-row breakdown.
|
||||
func TestScreen(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5})
|
||||
rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
|
||||
// Report: all 7 sections populated with citations.
|
||||
func TestReport(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
|
||||
}
|
||||
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
|
||||
// Subscribe -> run -> history row appears.
|
||||
func TestRoutineSubscribeRunHistory(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
|
||||
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
var created map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
rows, _ := s.DB.ListRoutines("demo")
|
||||
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||
rows, _ := s.DB.ListRoutines(u.UserKey)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("no routines")
|
||||
}
|
||||
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = do(s, "GET", "/api/routine-runs?limit=5", nil)
|
||||
rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
|
||||
var out struct {
|
||||
Runs []map[string]any `json:"runs"`
|
||||
}
|
||||
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
|
||||
// from the persisted report, unknown report 404s.
|
||||
func TestInterrogate(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
|
||||
rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
|
||||
}
|
||||
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
|
||||
if out.Answer == "" {
|
||||
t.Fatal("empty interrogation answer")
|
||||
}
|
||||
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
|
||||
rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
|
||||
}
|
||||
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
|
||||
// Concentrated fixture warns >40% sector; accuracy math covered.
|
||||
func TestPortfolioAndAccuracy(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "GET", "/api/portfolio/risk", nil)
|
||||
rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
rec = do(s, "GET", "/api/accuracy", nil)
|
||||
rec = doAuth(t, s, "GET", "/api/accuracy", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
|
||||
// Unknown routine types are rejected; missing ids 404.
|
||||
func TestRoutineValidation(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
|
||||
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, want 422", rec.Code)
|
||||
}
|
||||
rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
|
||||
rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
rec = do(s, "DELETE", "/api/routines/999999", nil)
|
||||
rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
rec = do(s, "DELETE", "/api/alerts/999999", nil)
|
||||
rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
@@ -225,27 +227,27 @@ func TestRoutineValidation(t *testing.T) {
|
||||
func TestDestinations(t *testing.T) {
|
||||
s := testServer(t)
|
||||
// Invalid kind -> 422.
|
||||
rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
|
||||
rec := doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, want 422", rec.Code)
|
||||
}
|
||||
// Telegram without chat_id -> 422.
|
||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
|
||||
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, want 422", rec.Code)
|
||||
}
|
||||
// Discord non-https -> 422.
|
||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
|
||||
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, want 422", rec.Code)
|
||||
}
|
||||
// Valid discord create -> 201.
|
||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
|
||||
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// List masks secrets.
|
||||
rec = do(s, "GET", "/api/destinations", nil)
|
||||
rec = doAuth(t, s, "GET", "/api/destinations", nil)
|
||||
var out struct {
|
||||
Destinations []map[string]any `json:"destinations"`
|
||||
}
|
||||
@@ -262,11 +264,11 @@ func TestDestinations(t *testing.T) {
|
||||
t.Fatalf("configured flag = %v", out.Destinations[0])
|
||||
}
|
||||
// Missing id -> 404 on patch and delete.
|
||||
rec = do(s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
|
||||
rec = doAuth(t, s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
rec = do(s, "DELETE", "/api/destinations/999999", nil)
|
||||
rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
fail("user store unavailable")
|
||||
return
|
||||
}
|
||||
// Ensure the login user owns the default watchlist on first login.
|
||||
// Ensure the login user owns the "semua" default watchlist on first login.
|
||||
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
|
||||
for _, t := range s.Cfg.Watchlist {
|
||||
seeds, _ := s.DB.AllTickers()
|
||||
if len(seeds) == 0 {
|
||||
seeds = s.Cfg.Watchlist
|
||||
}
|
||||
for _, t := range seeds {
|
||||
_ = s.DB.AddWatch(user.UserKey, t)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,120 @@ func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
|
||||
return s.DB.SessionUser(c.Value)
|
||||
}
|
||||
|
||||
// requireLogin is chi middleware: 401 unless a valid session cookie is
|
||||
// present. No X-User-Key/demo fallback — fitur dan filter milik user yg
|
||||
// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
|
||||
func (s *Server) requireLogin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.sessionUser(r); !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// localCreds decodes {username, password} with shared validation.
|
||||
func localCreds(r *http.Request) (string, string, bool) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
|
||||
}
|
||||
|
||||
// mintSession creates a session + sets the fs_session cookie.
|
||||
func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
|
||||
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "session store unavailable")
|
||||
return false
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
||||
Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// userJSON renders the /api/auth/me shape for one user.
|
||||
func (s *Server) userJSON(u *store.User) map[string]any {
|
||||
return map[string]any{
|
||||
"id": u.ID, "email": u.Email, "name": u.Name,
|
||||
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
|
||||
"google_configured": s.Cfg.HasGoogle(),
|
||||
}
|
||||
}
|
||||
|
||||
// seedWatchlistSemua gives a fresh user the "semua" default: every ticker
|
||||
// with stored data (fallback: config watchlist, last: BBCA).
|
||||
func (s *Server) seedWatchlistSemua(userKey string) {
|
||||
if wl, _ := s.DB.Watchlist(userKey); len(wl) > 0 {
|
||||
return
|
||||
}
|
||||
seeds, _ := s.DB.AllTickers()
|
||||
if len(seeds) == 0 {
|
||||
seeds = s.Cfg.Watchlist
|
||||
}
|
||||
if len(seeds) == 0 {
|
||||
seeds = []string{"BBCA"}
|
||||
}
|
||||
for _, t := range seeds {
|
||||
_ = s.DB.AddWatch(userKey, t)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthSignup serves POST /api/auth/signup {username, password}: registers a
|
||||
// local account, seeds the "semua" watchlist, logs in immediately.
|
||||
func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := localCreds(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.CreateLocalUser(username, password)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case store.ErrTaken:
|
||||
writeErr(w, http.StatusConflict, err.Error())
|
||||
case store.ErrBadUsername, store.ErrBadPassword:
|
||||
writeErr(w, http.StatusUnprocessableEntity, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
s.seedWatchlistSemua(user.UserKey)
|
||||
if !s.mintSession(w, user) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
|
||||
}
|
||||
|
||||
// AuthLogin serves POST /api/auth/login {username, password}: verifies the
|
||||
// local account and mints a session.
|
||||
func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := localCreds(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.CheckLocalUser(username, password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "username atau password salah")
|
||||
return
|
||||
}
|
||||
s.seedWatchlistSemua(user.UserKey)
|
||||
if !s.mintSession(w, user) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
|
||||
}
|
||||
|
||||
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
|
||||
type googleTokenResp struct {
|
||||
IDToken string `json:"id_token"`
|
||||
|
||||
@@ -19,7 +19,11 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
wl, _ := s.DB.Watchlist(s.userKey(r))
|
||||
if len(wl) == 0 {
|
||||
wl = s.Cfg.Watchlist
|
||||
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||
wl = all
|
||||
} else {
|
||||
wl = s.Cfg.Watchlist
|
||||
}
|
||||
}
|
||||
type accRow struct {
|
||||
Ticker string `json:"ticker"`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Gated routes 401 without session; public routes stay 200.
|
||||
func TestGatedRequiresLogin(t *testing.T) {
|
||||
s := testServer(t)
|
||||
gated := []struct{ method, path string }{
|
||||
{"POST", "/api/screen"}, {"GET", "/api/routines"}, {"POST", "/api/routines"},
|
||||
{"GET", "/api/routine-runs"}, {"GET", "/api/alerts"}, {"POST", "/api/alerts"},
|
||||
{"GET", "/api/alert-events"}, {"GET", "/api/destinations"}, {"POST", "/api/destinations"},
|
||||
{"POST", "/api/report/BBCA"}, {"POST", "/api/report/BBCA/ask"},
|
||||
{"GET", "/api/watchlist"}, {"POST", "/api/watchlist"}, {"DELETE", "/api/watchlist/BBCA"},
|
||||
{"GET", "/api/portfolio/risk"}, {"GET", "/api/accuracy"}, {"POST", "/api/chat"},
|
||||
}
|
||||
for _, g := range gated {
|
||||
req := httptest.NewRequest(g.method, g.path, nil) // no session cookie, no demo header
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s = %d, want 401", g.method, g.path, rec.Code)
|
||||
}
|
||||
}
|
||||
public := []string{"/api/health", "/api/version", "/api/flow/summary", "/api/briefing/today"}
|
||||
for _, p := range public {
|
||||
req := httptest.NewRequest("GET", p, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doAuth(t *testing.T, s *Server, method, path string, body any) *httptest.ResponseRecorder {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(raw)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||
if u == nil {
|
||||
var err error
|
||||
u, err = s.DB.CreateLocalUser("tester", "password1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tok, err := s.DB.CreateSession(u.ID, u.UserKey, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Signup -> login -> gated route works with cookie; dup/wrong rejected.
|
||||
func TestSignupLoginRoundTrip(t *testing.T) {
|
||||
s := testServer(t)
|
||||
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("signup = %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("dup signup = %d, want 409", rec.Code)
|
||||
}
|
||||
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "budi", "password": "salahpass"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong login = %d, want 401", rec.Code)
|
||||
}
|
||||
// Fresh signup seeds the "semua" watchlist (fixture tickers BBCA, TLKM).
|
||||
wl, _ := s.DB.Watchlist("u:local:budi")
|
||||
if len(wl) < 2 {
|
||||
t.Fatalf("semua watchlist = %v, want all fixture tickers", wl)
|
||||
}
|
||||
// Version endpoint reports a commit string.
|
||||
rec = do(s, "GET", "/api/version", nil)
|
||||
var v struct {
|
||||
Commit string `json:"commit"`
|
||||
StartedAt string `json:"started_at"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &v)
|
||||
if rec.Code != http.StatusOK || v.StartedAt == "" {
|
||||
t.Fatalf("version = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// AllTickers excludes pseudo tickers.
|
||||
all, _ := s.DB.AllTickers()
|
||||
for _, tk := range all {
|
||||
if tk == "IDX" || tk == "ROE" {
|
||||
t.Fatalf("AllTickers leaked pseudo %s", tk)
|
||||
}
|
||||
}
|
||||
if len(all) == 0 {
|
||||
t.Fatal("AllTickers empty on seeded db")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ import (
|
||||
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
|
||||
wl, _ := s.DB.Watchlist(s.userKey(r))
|
||||
if len(wl) == 0 {
|
||||
wl = s.Cfg.Watchlist
|
||||
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||
wl = all
|
||||
} else {
|
||||
wl = s.Cfg.Watchlist
|
||||
}
|
||||
}
|
||||
// Concentration: weight by latest close x assumed equal shares (seed-safe).
|
||||
type bar struct {
|
||||
|
||||
@@ -55,7 +55,11 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
|
||||
if len(universe) == 0 {
|
||||
universe, _ = s.DB.Watchlist(s.userKey(r))
|
||||
if len(universe) == 0 {
|
||||
universe = s.Cfg.Watchlist
|
||||
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||
universe = all
|
||||
} else {
|
||||
universe = s.Cfg.Watchlist
|
||||
}
|
||||
}
|
||||
}
|
||||
var rows []ScreenRow
|
||||
|
||||
@@ -35,6 +35,9 @@ type Server struct {
|
||||
LLM *llm.Client
|
||||
Validate *validator.Validate
|
||||
Hub *Hub
|
||||
// Commit + StartedAt power /api/version (CI anti-stale proof).
|
||||
Commit string
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// New builds a Server with all dependencies wired.
|
||||
@@ -45,6 +48,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
|
||||
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
|
||||
Validate: validator.New(),
|
||||
Hub: NewHub(),
|
||||
Commit: readCommit(),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
|
||||
Publish: srv.Hub.Publish}
|
||||
@@ -66,33 +71,41 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/auth/callback", s.AuthCallback)
|
||||
r.Get("/auth/me", s.AuthMe)
|
||||
r.Post("/auth/logout", s.AuthLogout)
|
||||
r.Post("/auth/signup", s.AuthSignup)
|
||||
r.Post("/auth/login", s.AuthLogin)
|
||||
r.Get("/version", s.Version)
|
||||
r.Get("/stream", s.Stream)
|
||||
// Publik baca: dashboard bisa dibuka tanpa login.
|
||||
r.Get("/flow/summary", s.FlowSummary)
|
||||
r.Get("/flow/broker", s.FlowBroker)
|
||||
r.Get("/flow/foreign", s.FlowForeign)
|
||||
r.Post("/screen", s.Screen)
|
||||
r.Get("/routines", s.ListRoutines)
|
||||
r.Post("/routines", s.CreateRoutine)
|
||||
r.Patch("/routines/{id}", s.UpdateRoutine)
|
||||
r.Delete("/routines/{id}", s.DeleteRoutine)
|
||||
r.Get("/routine-runs", s.RunHistory)
|
||||
r.Get("/briefing/today", s.BriefingToday)
|
||||
r.Get("/alerts", s.ListAlerts)
|
||||
r.Post("/alerts", s.CreateAlert)
|
||||
r.Delete("/alerts/{id}", s.DeleteAlert)
|
||||
r.Get("/alert-events", s.AlertEvents)
|
||||
r.Get("/destinations", s.ListDestinations)
|
||||
r.Post("/destinations", s.CreateDestination)
|
||||
r.Patch("/destinations/{id}", s.UpdateDestination)
|
||||
r.Delete("/destinations/{id}", s.DeleteDestination)
|
||||
r.Post("/report/{ticker}", s.BuildReport)
|
||||
r.Post("/report/{ticker}/ask", s.Interrogate)
|
||||
r.Get("/watchlist", s.GetWatchlist)
|
||||
r.Post("/watchlist", s.AddWatch)
|
||||
r.Delete("/watchlist/{ticker}", s.RemoveWatch)
|
||||
r.Get("/portfolio/risk", s.PortfolioRisk)
|
||||
r.Get("/accuracy", s.Accuracy)
|
||||
r.Post("/chat", s.Chat)
|
||||
// Fitur + filter: wajib login (session cookie, tanpa demo bypass).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireLogin)
|
||||
r.Post("/screen", s.Screen)
|
||||
r.Get("/routines", s.ListRoutines)
|
||||
r.Post("/routines", s.CreateRoutine)
|
||||
r.Patch("/routines/{id}", s.UpdateRoutine)
|
||||
r.Delete("/routines/{id}", s.DeleteRoutine)
|
||||
r.Get("/routine-runs", s.RunHistory)
|
||||
r.Get("/alerts", s.ListAlerts)
|
||||
r.Post("/alerts", s.CreateAlert)
|
||||
r.Delete("/alerts/{id}", s.DeleteAlert)
|
||||
r.Get("/alert-events", s.AlertEvents)
|
||||
r.Get("/destinations", s.ListDestinations)
|
||||
r.Post("/destinations", s.CreateDestination)
|
||||
r.Patch("/destinations/{id}", s.UpdateDestination)
|
||||
r.Delete("/destinations/{id}", s.DeleteDestination)
|
||||
r.Post("/report/{ticker}", s.BuildReport)
|
||||
r.Post("/report/{ticker}/ask", s.Interrogate)
|
||||
r.Get("/watchlist", s.GetWatchlist)
|
||||
r.Post("/watchlist", s.AddWatch)
|
||||
r.Delete("/watchlist/{ticker}", s.RemoveWatch)
|
||||
r.Get("/portfolio/risk", s.PortfolioRisk)
|
||||
r.Get("/accuracy", s.Accuracy)
|
||||
r.Post("/chat", s.Chat)
|
||||
})
|
||||
})
|
||||
if s.Cfg.StaticDir != "" {
|
||||
r.NotFound(s.spaHandler())
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version serves GET /api/version: deployed commit + process start time so CI
|
||||
// can prove the live process is the freshly deployed one (anti-stale).
|
||||
// Commit is resolved once at startup (see readCommit); an old process keeps
|
||||
// reporting its old commit even after CI writes a new version.txt.
|
||||
func (s *Server) Version(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"commit": s.Commit,
|
||||
"started_at": s.StartedAt.UTC().Format(time.RFC3339),
|
||||
"google_configured": s.Cfg.HasGoogle(),
|
||||
})
|
||||
}
|
||||
|
||||
// readCommit resolves the deployed commit once at startup: CI writes
|
||||
// $GITHUB_SHA to version.txt (WorkingDirectory) before restarting.
|
||||
func readCommit() string {
|
||||
if v := strings.TrimSpace(os.Getenv("FLOWSIGHT_COMMIT")); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, p := range []string{"version.txt", "/var/lib/flowsight/version.txt"} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if v := strings.TrimSpace(string(b)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -190,12 +190,16 @@ func (s *Scheduler) watchlist() []string {
|
||||
return s.watchlistFor(s.Cfg.DemoUserKey)
|
||||
}
|
||||
|
||||
// watchlistFor resolves one owner's watchlist (demo seed fallback kept).
|
||||
// watchlistFor resolves one owner's watchlist: personal first, then the
|
||||
// "semua" default (every ticker with stored data), then the config list.
|
||||
func (s *Scheduler) watchlistFor(owner string) []string {
|
||||
wl, err := s.DB.Watchlist(owner)
|
||||
if err == nil && len(wl) > 0 {
|
||||
return wl
|
||||
}
|
||||
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||
return all
|
||||
}
|
||||
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
|
||||
return s.Cfg.Watchlist
|
||||
}
|
||||
|
||||
@@ -5,10 +5,24 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
|
||||
// the scoping key used by every user-owned table.
|
||||
// the scoping key used by every user-owned table. Local accounts
|
||||
// (username+password signup) store google_sub='local:<username>' with
|
||||
// username+password_hash set, keeping session scoping unchanged.
|
||||
type authErr string
|
||||
|
||||
func (e authErr) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
ErrBadLogin authErr = "username atau password salah"
|
||||
ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
|
||||
ErrBadPassword authErr = "password minimal 8 karakter"
|
||||
ErrTaken authErr = "username sudah dipakai"
|
||||
)
|
||||
type User struct {
|
||||
ID int64
|
||||
GoogleSub string
|
||||
@@ -108,3 +122,66 @@ func (db *DB) DeleteSession(token string) error {
|
||||
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// localSub maps a username to its google_sub value for local accounts.
|
||||
func localSub(username string) string { return "local:" + strings.ToLower(username) }
|
||||
|
||||
// CreateLocalUser registers a username+password account (bcrypt cost 10).
|
||||
// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
|
||||
func (db *DB) CreateLocalUser(username, password string) (*User, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if !validUsername(username) {
|
||||
return nil, ErrBadUsername
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, ErrBadPassword
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub := localSub(username)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
|
||||
VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return nil, ErrTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return db.UserBySub(sub)
|
||||
}
|
||||
|
||||
// CheckLocalUser verifies username+password, returning the user on success.
|
||||
func (db *DB) CheckLocalUser(username, password string) (*User, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
var u User
|
||||
var hash string
|
||||
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
|
||||
FROM users WHERE username=?`, username).
|
||||
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
|
||||
return nil, ErrBadLogin
|
||||
}
|
||||
if hash == "" {
|
||||
return nil, ErrBadLogin // Google-only account, no local password
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return nil, ErrBadLogin
|
||||
}
|
||||
u.UserKey = UserKeyForSub(u.GoogleSub)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
|
||||
func validUsername(u string) bool {
|
||||
if len(u) < 3 || len(u) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range u {
|
||||
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- FlowSight schema v7: local username+password login (Google stays optional).
|
||||
-- Local accounts store google_sub='local:<username>' so session scoping
|
||||
-- (sessions join + UserKeyForSub) works unchanged.
|
||||
ALTER TABLE users ADD COLUMN username TEXT;
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
@@ -540,6 +540,28 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AllTickers returns every ticker that has snapshot data, excluding pseudo
|
||||
// tickers (IDX market-wide rows, ROE registry rows). This is the "semua"
|
||||
// default universe: dashboard, screener fallback, and scheduler depth all use
|
||||
// it when a user has no personal watchlist.
|
||||
func (db *DB) AllTickers() ([]string, error) {
|
||||
rows, err := db.Query(`SELECT DISTINCT ticker FROM snapshots
|
||||
WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddWatch inserts a ticker (idempotent).
|
||||
func (db *DB) AddWatch(userKey, ticker string) error {
|
||||
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
|
||||
|
||||
Reference in New Issue
Block a user