diff --git a/.gitignore b/.gitignore index b786662..a464491 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -data/** \ No newline at end of file +data/** +.env \ No newline at end of file diff --git a/Portifolio b/Portifolio index cff6b19..4804185 100755 Binary files a/Portifolio and b/Portifolio differ diff --git a/go.mod b/go.mod index c570ca5..75a006e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,11 @@ module Portifolio go 1.25.7 -require github.com/mattn/go-sqlite3 v1.14.37 // indirect +require github.com/mattn/go-sqlite3 v1.14.37 + +require ( + github.com/joho/godotenv v1.5.1 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect +) + +require github.com/FuLygon/go-totp/v2 v2.4.0 diff --git a/go.sum b/go.sum index 9c79a75..afa922c 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,8 @@ +github.com/FuLygon/go-totp/v2 v2.4.0 h1:NTgab5GCVHHGjIjjOzRKKKxa6PhMI9o/fs2fFrJqqhE= +github.com/FuLygon/go-totp/v2 v2.4.0/go.mod h1:bEwZp8rat339MqPzXZ7FDl5eOJuXpQ35aYbk1qZbWLM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= diff --git a/internal/middleware/session.go b/internal/middleware/session.go new file mode 100644 index 0000000..cb3bc24 --- /dev/null +++ b/internal/middleware/session.go @@ -0,0 +1,58 @@ +package middleware + +import ( + "net/http" + "sync" +) + +// In-memory session store — swap for Redis/DB in production +var ( + sessionStore = make(map[string]bool) + mu sync.RWMutex +) + +// RegisterSession adds a token to the store (call this after login) +func RegisterSession(token string) { + mu.Lock() + defer mu.Unlock() + sessionStore[token] = true +} + +// RevokeSession removes a token (call this on logout) +func RevokeSession(token string) { + mu.Lock() + defer mu.Unlock() + delete(sessionStore, token) +} + +// RequireSession is the middleware — wraps any handler that needs auth +func RequireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + // No cookie at all + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + mu.RLock() + valid := sessionStore[cookie.Value] + mu.RUnlock() + + if !valid { + // Cookie exists but token is unknown/expired + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + MaxAge: -1, // delete it + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/website/main.go b/internal/website/main.go index a56ff4d..9fb66a0 100644 --- a/internal/website/main.go +++ b/internal/website/main.go @@ -1,13 +1,19 @@ 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" ) @@ -21,13 +27,136 @@ func Getsite() http.HandlerFunc { } } -//go:embed static/styles.css -var styleCSS []byte +//go:embed static/login.html +var loginxHTML []byte -func Getstylesheet() http.HandlerFunc { +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(styleCSS) + 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) } } diff --git a/internal/website/static/admin.css b/internal/website/static/admin.css new file mode 100644 index 0000000..3341fa3 --- /dev/null +++ b/internal/website/static/admin.css @@ -0,0 +1,444 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap'); + +:root { + --bg: #f5efe7;; + --surface: #efefef; + --surface2:#e0e0e0; + --border: #8c8c8c; + --border2: #acacac; + --accent: #c8a96e; + --accent2: #f0d060; + --muted: #5a5a68; + --muted2: #3d3d4a; + --text: #000000; + --text-dim: #9e9a92; + --red: #f06060; + --blue: #60c0f0; + --radius: 2px; + --mono: 'Space Mono', monospace; + --serif: 'Fraunces', Georgia, serif; +} + + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 14px; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--mono); + min-height: 100vh; +} + +/* GRID LAYOUT */ +.app { + display: grid; + grid-template-columns: 220px 1fr; + grid-template-rows: 56px 1fr; + min-height: 100vh; +} + +/* TOP NAV */ +.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 24px; + padding: 0 24px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.topbar-logo { + font-family: var(--serif); + font-size: 1rem; + font-style: italic; + font-weight: 300; + color: var(--text); + text-decoration: none; +} +.topbar-logo span { color: var(--accent); } +.topbar-tag { + font-size: 0.71rem; + color: var(--muted2); + letter-spacing: 0.04em; +} +.topbar-spacer { flex: 1; } +.status-pill { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.71rem; + color: var(--muted2); +} +.dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent); + animation: pulse 2.5s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.dot.err { background: var(--red); animation: none; } +.dot.loading { background: var(--muted); animation: pulse 1s linear infinite; } + +/* SIDEBAR */ +.sidebar { + border-right: 1px solid var(--border); + background: var(--surface); + padding: 20px 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.nav-section-label { + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 10px 18px 4px; +} +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 18px; + font-size: 0.75rem; + color: var(--muted2); + text-decoration: none; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + transition: color 0.15s, background 0.15s; + border-left: 2px solid transparent; +} +.nav-item:hover { color: var(--text); background: var(--surface2); } +.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(200,240,96,0.05); } +.nav-icon { width: 14px; opacity: 0.7; font-style: normal; } + +/* MAIN */ +.main { + padding: 28px 32px; + overflow-y: auto; + max-height: calc(100vh - 56px); +} + +/* PAGE HEADER */ +.page-header { + margin-bottom: 28px; + display: flex; + align-items: flex-end; + gap: 16px; +} +.page-title { + font-family: var(--serif); + font-size: 2rem; + font-weight: 300; + line-height: 1; +} +.page-title span { font-style: italic; color: var(--accent); } +.page-subtitle { + font-size: 0.68rem; + color: var(--muted2); + padding-bottom: 4px; + letter-spacing: 0.04em; +} + +/* SECTION */ +.section { margin-bottom: 32px; } +.section-label { + font-size: 0.68rem; + color: var(--muted2); + letter-spacing: 0.08em; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 10px; +} +.section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} +.section-label span { color: var(--accent); } + +/* SUMMARY CARDS */ +.cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 32px; +} +.card { + background: var(--surface); + border: 1px solid var(--border); + padding: 16px 18px; +} +.card-label { + font-size: 0.65rem; + color: var(--muted2); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 8px; +} +.card-value { + font-family: var(--serif); + font-size: 1.6rem; + font-weight: 300; + color: var(--text); +} +.card-value.accent { color: var(--accent); } +.card-value.dim { color: var(--muted); } + +/* PANEL / FORM */ +.panel { + background: var(--surface); + border: 1px solid var(--border); +} +.panel-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; +} +.panel-title { + font-size: 0.75rem; + letter-spacing: 0.04em; +} +.panel-title span { color: var(--accent); } +.panel-body { padding: 20px; } + +/* FORM GRID */ +.form-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +.form-grid.two { grid-template-columns: repeat(2, 1fr); } +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} +.form-group.full { grid-column: 1 / -1; } +.form-label { + font-size: 0.65rem; + color: var(--muted2); + letter-spacing: 0.07em; + text-transform: uppercase; +} +.form-label .required { color: var(--red); margin-left: 3px; } + +input, select, textarea { + background: var(--bg); + border: 1px solid var(--border2); + color: var(--text); + font-family: var(--mono); + font-size: 0.78rem; + padding: 9px 12px; + border-radius: var(--radius); + outline: none; + width: 100%; + transition: border-color 0.15s, box-shadow 0.15s; + -webkit-appearance: none; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(200,240,96,0.1); +} +input::placeholder { color: var(--muted); } +select option { background: var(--surface2); } + +.type-toggle { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border: 1px solid var(--border2); + border-radius: var(--radius); + overflow: hidden; +} +.type-btn { + padding: 9px 8px; + font-family: var(--mono); + font-size: 0.72rem; + border: none; + cursor: pointer; + background: var(--bg); + color: var(--muted2); + transition: background 0.15s, color 0.15s; + letter-spacing: 0.03em; + border-right: 1px solid var(--border2); +} +.type-btn:last-child { border-right: none; } +.type-btn.active-buy { background: rgba(96,192,240,0.12); color: var(--blue); } +.type-btn.active-sell { background: rgba(240,96,96,0.12); color: var(--red); } +.type-btn.active-div { background: rgba(200,240,96,0.12); color: var(--accent); } + +/* DIVIDEND FIELDS */ +.dividend-fields { + grid-column: 1 / -1; + display: none; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + margin-top: 4px; +} +.dividend-fields.visible { display: grid; } +.div-label { + grid-column: 1 / -1; + font-size: 0.63rem; + color: var(--accent); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: -8px; +} + +/* FORM ACTIONS */ +.form-actions { + display: flex; + align-items: center; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--border); + margin-top: 4px; + grid-column: 1 / -1; +} +.btn { + font-family: var(--mono); + font-size: 0.72rem; + padding: 9px 20px; + border: 1px solid transparent; + cursor: pointer; + letter-spacing: 0.04em; + transition: background 0.15s, color 0.15s, transform 0.1s; + border-radius: var(--radius); +} +.btn:active { transform: scale(0.98); } +.btn-primary { + background: var(--accent); + color: #0d0d0d; + font-weight: 700; +} +.btn-primary:hover { background: #d8ff70; } +.btn-ghost { + background: transparent; + color: var(--muted2); + border-color: var(--border2); +} +.btn-ghost:hover { color: var(--text); border-color: var(--muted); } + +/* FEEDBACK */ +.feedback { + font-size: 0.72rem; + padding: 4px 12px; + border-radius: var(--radius); + display: none; +} +.feedback.success { display: inline; color: var(--accent); } +.feedback.error { display: inline; color: var(--red); } + +/* TABLE */ +.table-wrap { + overflow-x: auto; + border: 1px solid var(--border); +} +table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} +thead { + background: var(--surface2); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 0.62rem; + font-weight: 400; + color: var(--muted2); + letter-spacing: 0.07em; + text-transform: uppercase; + white-space: nowrap; + border-bottom: 1px solid var(--border); +} +td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + color: var(--text); + white-space: nowrap; +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(255,255,255,0.025); } +.td-sym { + font-weight: 700; + color: var(--accent); + font-size: 0.78rem; +} +.td-muted { color: var(--muted2); } +.td-buy { color: var(--blue); } +.td-sell { color: var(--red); } +.td-div { color: var(--accent); } +.td-right { text-align: right; } + +.badge { + font-size: 0.62rem; + padding: 2px 8px; + border-radius: 20px; + letter-spacing: 0.04em; +} +.badge-buy { background: rgba(96,192,240,0.12); color: var(--blue); } +.badge-sell { background: rgba(240,96,96,0.12); color: var(--red); } +.badge-div { background: rgba(200,240,96,0.12); color: var(--accent); } + +/* TABS */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: -1px; +} +.tab { + padding: 10px 20px; + font-size: 0.72rem; + cursor: pointer; + color: var(--muted2); + border: none; + background: none; + font-family: var(--mono); + border-bottom: 2px solid transparent; + transition: color 0.15s; + letter-spacing: 0.03em; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* PILL TAG */ +.tag-pill { + font-size: 0.60rem; + padding: 2px 7px; + background: var(--surface2); + border: 1px solid var(--border); + color: var(--muted2); + border-radius: 20px; + letter-spacing: 0.04em; +} + +/* PAGES */ +.page { display: none; } +.page.active { display: block; } + +/* RESPONSIVE NOTE */ +@media(max-width:900px) { + .app { grid-template-columns: 1fr; } + .sidebar { display: none; } + .cards { grid-template-columns: repeat(2, 1fr); } + .form-grid { grid-template-columns: 1fr; } + .dividend-fields { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/internal/website/static/admin.html b/internal/website/static/admin.html new file mode 100644 index 0000000..d90bfe8 --- /dev/null +++ b/internal/website/static/admin.html @@ -0,0 +1,582 @@ + + + + + +Portfolio Admin — Samantha Friis + + + + +
+ + +
+ + // portfolio admin +
+
+ + live +
+
+ + + + + +
+ + +
+ + +
+
+

Positions

+

+
+
+

Total Shares

+

+
+
+

Total Trades

+

+
+
+

Currencies

+

+
+
+ + +
+ + + + + + + + + + +
SymbolCurrencySharesCost BasisWeight
loading…
+
+ + +
+ + + + + + + + + + +
SymbolCurrencyPriceShares OutstandingMarket Cap
loading…
+
+
+ + +
+ +
+ + + + + + + + + + +
SymbolCurrencySharesCost BasisWeight
loading…
+
+
+ + +
+ +
+ + + + + + + + + + +
DateSymbolTypeSharesPriceCurrencyTotal
loading…
+
+
+ + +
+ + +
+
+ // new trade + POST /trades +
+
+
+ + +
+ +
+ + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ dividend fields + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ +
+
+
+ + +
+
+ // payload preview +
+
+
{}
+
+
+
+ + +
+ + +
+
+ // new company + POST /company +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+
+
+ +
+
+ // payload preview +
+
+
{}
+
+
+
+ +
+
+ + + + + diff --git a/internal/website/static/index.html b/internal/website/static/index.html index e591dc9..399c538 100644 --- a/internal/website/static/index.html +++ b/internal/website/static/index.html @@ -5,14 +5,15 @@ Portfolio — Samantha Friis - +
- ← back + ← back, + login

// portfolio

Investment
Portfolio

Equity positions  ·  Personal research

diff --git a/internal/website/static/login.css b/internal/website/static/login.css new file mode 100644 index 0000000..281dd3f --- /dev/null +++ b/internal/website/static/login.css @@ -0,0 +1,224 @@ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :root { + --ink: #1a1714; + --paper: #f5f0e8; + --accent: #c4622d; + --muted: #8c8070; + --border: #d6cfc3; + --field-bg: #ece7dd; + } + + html, body { + height: 100%; + background: var(--paper); + color: var(--ink); + font-family: 'Space Mono', monospace; + font-size: 13px; + } + + body { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + } + + /* Grain overlay */ + body::before { + content: ''; + position: fixed; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + pointer-events: none; + z-index: 0; + opacity: 0.5; + } + + .container { + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; + animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + @keyframes rise { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } + } + + header { + margin-bottom: 40px; + } + + .back { + display: inline-block; + font-family: 'Space Mono', monospace; + font-size: 11px; + color: var(--muted); + text-decoration: none; + letter-spacing: 0.04em; + margin-bottom: 28px; + transition: color 0.2s; + } + .back:hover { color: var(--ink); } + + .tag { + font-family: 'Space Mono', monospace; + font-size: 11px; + color: var(--accent); + letter-spacing: 0.08em; + margin-bottom: 10px; + } + + h1 { + font-family: 'Fraunces', serif; + font-weight: 300; + font-size: clamp(36px, 8vw, 52px); + line-height: 1.05; + letter-spacing: -0.02em; + color: var(--ink); + } + + h1 span { + font-style: italic; + color: var(--accent); + } + + .header-sub { + margin-top: 10px; + font-size: 11px; + color: var(--muted); + letter-spacing: 0.06em; + } + + /* Rule */ + .rule { + width: 100%; + height: 1px; + background: var(--border); + margin: 32px 0; + } + + /* Form */ + .form-group { + margin-bottom: 20px; + } + + label { + display: block; + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 7px; + } + + input[type="text"], + input[type="password"] { + width: 100%; + padding: 11px 14px; + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 2px; + font-family: 'Space Mono', monospace; + font-size: 13px; + color: var(--ink); + outline: none; + transition: border-color 0.2s, background 0.2s; + -webkit-appearance: none; + } + + input[type="text"]:focus, + input[type="password"]:focus { + border-color: var(--accent); + background: var(--paper); + } + + input::placeholder { color: var(--muted); opacity: 0.7; } + + .form-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 28px; + gap: 16px; + } + + .btn-login { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--ink); + color: var(--paper); + font-family: 'Space Mono', monospace; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: none; + padding: 12px 24px; + cursor: pointer; + border-radius: 2px; + transition: background 0.2s, transform 0.15s; + } + + .btn-login:hover { + background: var(--accent); + transform: translateY(-1px); + } + .btn-login:active { transform: translateY(0); } + + .btn-login svg { + transition: transform 0.2s; + } + .btn-login:hover svg { transform: translateX(3px); } + + .forgot { + font-size: 11px; + color: var(--muted); + text-decoration: none; + letter-spacing: 0.03em; + border-bottom: 1px solid transparent; + transition: color 0.2s, border-color 0.2s; + } + .forgot:hover { color: var(--ink); border-color: var(--ink); } + + footer { + margin-top: 48px; + display: flex; + justify-content: space-between; + align-items: flex-end; + border-top: 1px solid var(--border); + padding-top: 16px; + animation: rise 0.6s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + footer a { + font-size: 10px; + color: var(--muted); + text-decoration: none; + letter-spacing: 0.04em; + transition: color 0.2s; + } + footer a:hover { color: var(--accent); } + + .footer-copy { + font-size: 10px; + color: var(--border); + letter-spacing: 0.04em; + } + + /* Decorative corner mark */ + .corner-mark { + position: fixed; + bottom: 24px; + right: 28px; + font-family: 'Fraunces', serif; + font-style: italic; + font-size: 11px; + color: var(--border); + letter-spacing: 0.06em; + z-index: 1; + } \ No newline at end of file diff --git a/internal/website/static/login.html b/internal/website/static/login.html new file mode 100644 index 0000000..4e73d5f --- /dev/null +++ b/internal/website/static/login.html @@ -0,0 +1,67 @@ + + + + + + Portfolio — Samantha Friis + + + + + +
+
+ ← back +

// portfolio

+

Investment
Portfolio

+

Equity positions  ·  Personal research

+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+ +

SF — 2026

+ + + \ No newline at end of file diff --git a/internal/website/static/styles.css b/internal/website/static/styles.css index 7e38c14..43323ae 100644 --- a/internal/website/static/styles.css +++ b/internal/website/static/styles.css @@ -1,5 +1,5 @@ :root { - --bg: #f4f4f4; + --bg: #f5efe7;; --surface: #efefef; --surface2:#e0e0e0; --border: #8c8c8c; diff --git a/main.go b/main.go index 211bb1a..443bc27 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "Portifolio/internal/database" + "Portifolio/internal/middleware" "Portifolio/internal/service" "Portifolio/internal/website" "database/sql" @@ -9,6 +10,9 @@ import ( "fmt" "log" "net/http" + "os" + + "github.com/joho/godotenv" _ "github.com/mattn/go-sqlite3" ) @@ -32,12 +36,32 @@ func corsMiddleware(next http.Handler) http.Handler { }) } +func EnvCheck() error { + + if os.Getenv("username") == "" { + return fmt.Errorf("Missing: username") + } + if os.Getenv("password") == "" { + return fmt.Errorf("Missing: password") + } + if os.Getenv("AUTH_TOTP_SECRET") == "" { + return fmt.Errorf("Missing: AUTH_TOTP_SECRET") + } + + return nil +} + func RegisterRoutes(mux *http.ServeMux, db *sql.DB) { svc := service.New(db) // ← create it here, once // ── Website (HTMX fragments) ────────────────────────────── mux.HandleFunc("/", website.Getsite()) - mux.HandleFunc("/style", website.Getstylesheet()) + mux.HandleFunc("/styles/main", website.GetstylesheetMain()) + mux.HandleFunc("/styles/login", website.GetstylesheetLogin()) + mux.HandleFunc("/styles/admin", website.GetstylesheetAdmin()) + mux.Handle("/admin", middleware.RequireSession(website.GetAdmin())) + mux.HandleFunc("/login", website.GetAdminLogin()) + mux.HandleFunc("POST /auth/login", website.LoginHandler()) mux.HandleFunc("/health/fragment", website.HealthFragment(svc)) mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc)) mux.HandleFunc("/company/fragment", website.CompanyFragment(svc)) @@ -47,24 +71,19 @@ func RegisterRoutes(mux *http.ServeMux, db *sql.DB) { // ── API (JSON) ──────────────────────────────────────────── mux.HandleFunc("/health", svc.HealthHandler()) - // Trades mux.HandleFunc("POST /trade/add", svc.AddTradeHandler()) mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler()) //mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc)) - // Positions mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler()) //mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc)) //mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc)) - // Company mux.HandleFunc("POST /company/add", svc.AddCompanyHandler()) mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler()) mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories()) //mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc)) //mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc)) - // Currency mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler()) mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler()) - // Revenue mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler()) mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler()) } @@ -88,6 +107,15 @@ func main() { log.Fatal("Failed to connect to database:", err) } + err = godotenv.Load(".env") + if err != nil { + log.Fatal("Error loading .env file") + } + err = EnvCheck() + if err != nil { + log.Fatal("Env vars not found:", err) + } + database.InitDB(db) fmt.Println("Connected to SQLite database")