Files
Portfolio-Engine/internal/website/main.go
2026-04-30 08:02:03 +02:00

369 lines
9.9 KiB
Go

package website
import (
"Portifolio/internal/middleware"
"Portifolio/internal/model"
"Portifolio/internal/service"
"crypto/rand"
_ "embed"
"encoding/base64"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/FuLygon/go-totp/v2"
_ "github.com/mattn/go-sqlite3"
)
//go:embed static/index.html
var indexHTML []byte
func Getsite() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
}
}
//go:embed static/login.html
var loginxHTML []byte
func GetAdminLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(loginxHTML)
}
}
type contextKey string
const ContextKeyRequestTime contextKey = "requestTime"
func LoginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// checked once when the handler is registered
envUsername := os.Getenv("username")
envPassword := os.Getenv("password")
envTOTPSecret := os.Getenv("AUTH_TOTP_SECRET")
if envUsername == "" || envPassword == "" || envTOTPSecret == "" {
http.Error(w, "env var missing", http.StatusInternalServerError)
}
ts, ok := r.Context().Value(ContextKeyRequestTime).(time.Time)
if !ok {
ts = time.Now()
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form data", http.StatusBadRequest)
return
}
password := r.FormValue("password")
username := r.FormValue("username")
code := r.FormValue("auth_code")
fmt.Println(username, password, code)
if password == "" || username == "" || code == "" {
http.Error(w, "form value is empty", http.StatusBadRequest)
return
}
if password != envPassword || username != envUsername {
http.Error(w, "username or password is not valid", http.StatusUnauthorized)
return
}
v := totp.Validator{
Algorithm: totp.AlgorithmSHA1,
Digits: 6,
Period: 30,
Secret: envTOTPSecret,
}
valid, err := v.ValidateWithTimestamp(code, ts.Unix())
if err != nil {
http.Error(w, fmt.Sprintf("error validating TOTP code: %s", err), http.StatusUnauthorized)
return
}
if !valid {
http.Error(w, "invalid auth code", http.StatusUnauthorized)
return
}
// In your login POST handler, after setting the cookie:
token := generateSessionToken()
middleware.RegisterSession(token) // <-- register before writing cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
w.Header().Set("HX-Redirect", "/admin")
w.WriteHeader(http.StatusOK)
}
}
// Simple session token generator
func generateSessionToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)
}
//go:embed static/admin.html
var adminHTML []byte
func GetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(adminHTML)
}
}
//go:embed static/styles.css
var styleCSSmain []byte
func GetstylesheetMain() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSmain)
}
}
//go:embed static/login.css
var styleCSSlogin []byte
func GetstylesheetLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSlogin)
}
}
//go:embed static/admin.css
var styleCSSadmin []byte
func GetstylesheetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSadmin)
}
}
func HealthFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := svc.CheckHealth()
status := h["status"].(string)
uptime := h["uptime"].(string)
dotClass := "ok"
statusText := "backend ok"
if status != "ok" {
dotClass = "error"
statusText = "backend " + status
w.WriteHeader(http.StatusServiceUnavailable) // ← triggers htmx:responseError
}
dbInfo := h["database"].(map[string]any)
dbStatus := dbInfo["status"].(string)
dbLatency := dbInfo["latency"].(string)
dbText := "db " + dbStatus
if dbLatency != "" {
dbText += " · " + dbLatency
}
fmt.Fprintf(w, `
<span class="status-dot %s"></span>
<span>%s</span>
<span class="status-sep">·</span>
<span style="color:var(--muted)">up %s</span>
<span class="status-sep">·</span>
<span style="color:var(--muted)">%s</span>
<span class="status-spacer"></span>
<button class="refresh-btn"
hx-get="/health/fragment"
hx-target="#statusBar"
hx-swap="innerHTML">↺ refresh</button>
`, dotClass, statusText, uptime, dbText)
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func PositionsFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
positions, err := svc.GetPositions()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(positions) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
var total float64
for _, p := range positions {
total += p.CostBasis
}
for _, p := range positions {
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
if total > 0 {
weight = p.CostBasis / total * 100
}
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td><span class="currency-badge">%s</span></td>
<td>%s</td>
<td>%s</td>
<td><div class="weight-cell">%.1f%%
<div class="weight-bar-track">
<div class="weight-bar-fill" style="width:%.0f%%"></div>
</div>
</div></td>
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
}
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func CompanyFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companies, err := svc.GetAllCompanies()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(companies) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
for _, c := range companies {
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td>%s</td>
<td>%.2f</td>
<td>%d</td>
<td>%.2f</td>
</tr>`, c.Symbol, c.CurrencyCode, c.Price, c.SharesOutstanding, c.Price*float64(c.SharesOutstanding))
}
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func SummaryFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
positions, err := svc.GetPositions()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(positions) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
var total float64
for _, p := range positions {
total += p.CostBasis
}
for _, p := range positions {
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
if total > 0 {
weight = p.CostBasis / total * 100
}
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td><span class="currency-badge">%s</span></td>
<td>%s</td>
<td>%s</td>
<td><div class="weight-cell">%.1f%%
<div class="weight-bar-track">
<div class="weight-bar-fill" style="width:%.0f%%"></div>
</div>
</div></td>
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
}
}
}
func fmtNum(v float64) string {
if v == 0 {
return "—"
}
return strconv.FormatFloat(v, 'f', 2, 64)
}
func fmtInt(v int) string {
return strconv.Itoa(v)
}
var productLabels = map[model.TradeProduct]string{
model.StockTrade: "Stock",
model.OptionCallTrade: "Call Option",
model.OptionPutTrade: "Put Option",
model.CurrencyTrade: "Currency",
model.BondTrade: "Bond",
}
func TradeFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
trades, err := svc.GetTrades()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="7">load failed: %s</td></tr>`, err)
return
}
if len(trades) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="7">no trades</td></tr>`)
return
}
for _, t := range trades {
dir, cls := "SELL", "dir-sell"
if t.Type == model.BuyType {
dir, cls = "BUY", "dir-buy"
}
label := productLabels[t.Product]
fmt.Fprintf(w, `<tr>
<td><span class="trade-ticker">%s</span></td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td class="%s">%s</td>
<td>%s</td>
<td>%s</td>
</tr>`, t.Symbol, label, t.CurrencyCode,
t.Date.Format("2006-01-02"), cls, dir,
fmtNum(float64(t.Shares)), fmtNum(t.Price))
}
}
}
func TradeCount(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
trades, err := svc.GetTrades()
if err != nil {
fmt.Fprint(w, "—")
return
}
count := len(trades)
if count == 1 {
fmt.Fprint(w, "1 trade")
} else {
fmt.Fprintf(w, "%d trades", count)
}
}
}