diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62de0f0..4c6d1dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: sleep 3 && \ sudo systemctl status flowsight --no-pager --no-legend | head -5" - - name: Verify service + - name: Verify service anti-stale run: | ssh "$VPS_USER@$VPS_HOST" \ "curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7cd9a8b..93a027d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) } } diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 303d88f..61548d3 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -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) } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index d4e8f57..4c58413 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -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"` diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go index f5bb02c..2537898 100644 --- a/backend/internal/api/flow.go +++ b/backend/internal/api/flow.go @@ -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"` diff --git a/backend/internal/api/gated_test.go b/backend/internal/api/gated_test.go new file mode 100644 index 0000000..d807253 --- /dev/null +++ b/backend/internal/api/gated_test.go @@ -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") + } +} diff --git a/backend/internal/api/portfolio.go b/backend/internal/api/portfolio.go index 3122bf5..59b6bc3 100644 --- a/backend/internal/api/portfolio.go +++ b/backend/internal/api/portfolio.go @@ -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 { diff --git a/backend/internal/api/screen.go b/backend/internal/api/screen.go index 8579823..f64b575 100644 --- a/backend/internal/api/screen.go +++ b/backend/internal/api/screen.go @@ -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 diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 2cffe39..400d69e 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -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()) diff --git a/backend/internal/api/version.go b/backend/internal/api/version.go new file mode 100644 index 0000000..7dc3e41 --- /dev/null +++ b/backend/internal/api/version.go @@ -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" +} diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go index a7ade6e..810d345 100644 --- a/backend/internal/scheduler/scheduler.go +++ b/backend/internal/scheduler/scheduler.go @@ -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 } diff --git a/backend/internal/store/auth.go b/backend/internal/store/auth.go index cca5893..ce33f26 100644 --- a/backend/internal/store/auth.go +++ b/backend/internal/store/auth.go @@ -5,10 +5,24 @@ import ( "encoding/hex" "strings" "time" + + "golang.org/x/crypto/bcrypt" ) // User is one Google-authenticated account. UserKey (`u:`) 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:' 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 +} diff --git a/backend/internal/store/migrations/0007_local_auth.sql b/backend/internal/store/migrations/0007_local_auth.sql new file mode 100644 index 0000000..2f2f767 --- /dev/null +++ b/backend/internal/store/migrations/0007_local_auth.sql @@ -0,0 +1,6 @@ +-- FlowSight schema v7: local username+password login (Google stays optional). +-- Local accounts store google_sub='local:' 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); diff --git a/backend/internal/store/rows.go b/backend/internal/store/rows.go index 0f13cd2..c0bab67 100644 --- a/backend/internal/store/rows.go +++ b/backend/internal/store/rows.go @@ -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(?,?,?) diff --git a/web/src/components/auth.tsx b/web/src/components/auth.tsx index cd4b3a5..78d3d90 100644 --- a/web/src/components/auth.tsx +++ b/web/src/components/auth.tsx @@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js"; import { api, type AuthUser } from "../lib/api"; import { Button } from "./ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; // Theme: .dark class on (shadcn convention). Default dark. function theme(): string { @@ -30,7 +31,30 @@ export function useAuth() { const [me, { refetch }] = createResource(async (): Promise => { try { return (await api.me()).user; } catch { return null; } }); - return { me, refetch }; + const [version] = createResource(async () => { + try { return await api.version(); } catch { return null; } + }); + return { me, refetch, version }; +} + +// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout. +export function ButuhLogin(props: { fitur: string }) { + return ( +
+ + + πŸ”’ {props.fitur} perlu login + + Biar datanya milik kamu sendiri (watchlist, notifikasi, report) β€” + daftar gratis, cukup username + password. + + + + + + +
+ ); } export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) { diff --git a/web/src/index.tsx b/web/src/index.tsx index 6bc9cc9..dbaf2f1 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,8 +1,8 @@ import { render } from "solid-js/web"; -import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router"; -import { createSignal, Show } from "solid-js"; +import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router"; +import { createResource, createSignal, Show } from "solid-js"; import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat"; -import { ThemeToggle, useAuth, AuthButton } from "./components/auth"; +import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth"; import { Button } from "./components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card"; import { TextField, TextFieldInput } from "./components/ui/text-field"; @@ -15,7 +15,7 @@ import Report from "./pages/Report"; import Portfolio from "./pages/Portfolio"; import "./styles/globals.css"; -const NAV = [ +const NAV_ALL = [ { href: "/", icon: "πŸ“Š", label: "Dashboard" }, { href: "/routines", icon: "πŸ—“οΈ", label: "Routines" }, { href: "/screener", icon: "πŸ”", label: "Screener" }, @@ -23,14 +23,16 @@ const NAV = [ { href: "/portfolio", icon: "πŸ’Ό", label: "Portfolio" }, ]; -function Nav() { +function Nav(props: { loggedIn: boolean }) { const loc = useLocation(); + // Belum login: hanya Dashboard yg terlihat (fitur lain di-hide). + const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1); return (