first commit
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
PORT=8082
|
||||
DATABASE_PATH=./arcline-billing.db
|
||||
|
||||
SMTP_HOST=mail.spacemail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=blake@arclineit.com
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=support@arclineit.com
|
||||
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
STRIPE_PRICE_SHARED_STARTER=price_...
|
||||
STRIPE_PRICE_SHARED_PRO=price_...
|
||||
STRIPE_PRICE_SHARED_BUSINESS=price_...
|
||||
STRIPE_PRICE_WP_STARTER=price_...
|
||||
STRIPE_PRICE_WP_PRO=price_...
|
||||
STRIPE_PRICE_WP_BUSINESS=price_...
|
||||
STRIPE_PRICE_VPS_1=price_...
|
||||
STRIPE_PRICE_VPS_2=price_...
|
||||
STRIPE_PRICE_VPS_3=price_...
|
||||
STRIPE_PRICE_VPS_4=price_...
|
||||
|
||||
BASE_URL=https://client.arclineit.com
|
||||
SESSION_SECURE=true
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Go build output
|
||||
arcline-billing
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
|
||||
# SQLite database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
27
go.mod
Normal file
27
go.mod
Normal file
@@ -0,0 +1,27 @@
|
||||
module arclineit.com/billing
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stripe/stripe-go/v81 v81.4.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/time v0.7.0
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
75
go.sum
Normal file
75
go.sum
Normal file
@@ -0,0 +1,75 @@
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
194
internal/auth/auth.go
Normal file
194
internal/auth/auth.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
bcryptCost = 12
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
resetTokenTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const contextKeyCustomerID contextKey = iota
|
||||
|
||||
// HashPassword hashes plain with bcrypt cost 12.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CheckPassword returns true when plain matches the stored bcrypt hash.
|
||||
func CheckPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// CreateSession inserts a new session row and returns the random token.
|
||||
func CreateSession(db *sql.DB, customerID int64) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("rand token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
expiresAt := time.Now().UTC().Add(SessionTTL).Format(time.RFC3339)
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO sessions (token, customer_id, expires_at) VALUES (?, ?, ?)`,
|
||||
token, customerID, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetSession looks up a session token and returns the associated customer ID.
|
||||
func GetSession(db *sql.DB, token string) (int64, error) {
|
||||
var customerID int64
|
||||
var expiresAt string
|
||||
|
||||
err := db.QueryRow(
|
||||
`SELECT customer_id, expires_at FROM sessions WHERE token = ?`,
|
||||
token,
|
||||
).Scan(&customerID, &expiresAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, fmt.Errorf("session not found")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query session: %w", err)
|
||||
}
|
||||
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse expires_at: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(exp) {
|
||||
_, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
|
||||
return 0, fmt.Errorf("session expired")
|
||||
}
|
||||
|
||||
return customerID, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session from the database.
|
||||
func DeleteSession(db *sql.DB, token string) error {
|
||||
_, err := db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateResetToken inserts a new password_resets row and returns the token.
|
||||
func CreateResetToken(db *sql.DB, customerID int64) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("rand token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
expiresAt := time.Now().UTC().Add(resetTokenTTL).Format(time.RFC3339)
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO password_resets (token, customer_id, expires_at) VALUES (?, ?, ?)`,
|
||||
token, customerID, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert reset token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateResetToken checks the token is valid and unused, returns customer ID.
|
||||
func ValidateResetToken(db *sql.DB, token string) (int64, error) {
|
||||
var customerID int64
|
||||
var expiresAt string
|
||||
var used int
|
||||
|
||||
err := db.QueryRow(
|
||||
`SELECT customer_id, expires_at, used FROM password_resets WHERE token = ?`,
|
||||
token,
|
||||
).Scan(&customerID, &expiresAt, &used)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, fmt.Errorf("reset token not found")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query reset token: %w", err)
|
||||
}
|
||||
|
||||
if used != 0 {
|
||||
return 0, fmt.Errorf("reset token already used")
|
||||
}
|
||||
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse expires_at: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(exp) {
|
||||
return 0, fmt.Errorf("reset token expired")
|
||||
}
|
||||
|
||||
return customerID, nil
|
||||
}
|
||||
|
||||
// ConsumeResetToken marks the token as used.
|
||||
func ConsumeResetToken(db *sql.DB, token string) error {
|
||||
_, err := db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume reset token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Middleware returns HTTP middleware that validates the session cookie and
|
||||
// injects the customer ID into the request context.
|
||||
func Middleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
customerID, err := GetSession(db, cookie.Value)
|
||||
if err != nil {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), contextKeyCustomerID, customerID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerIDFromContext extracts the customer ID stored by Middleware.
|
||||
func CustomerIDFromContext(ctx context.Context) int64 {
|
||||
v, _ := ctx.Value(contextKeyCustomerID).(int64)
|
||||
return v
|
||||
}
|
||||
88
internal/db/db.go
Normal file
88
internal/db/db.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Open opens (or creates) the SQLite database and runs schema migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
|
||||
if err := runSchema(db); err != nil {
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database ready", "path", path)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func runSchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
stripe_customer_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
stripe_subscription_id TEXT NOT NULL UNIQUE,
|
||||
stripe_price_id TEXT NOT NULL DEFAULT '',
|
||||
plan_name TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
current_period_end TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
stripe_invoice_id TEXT NOT NULL UNIQUE,
|
||||
amount_cents INTEGER NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'usd',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
invoice_pdf_url TEXT NOT NULL DEFAULT '',
|
||||
period_start TEXT NOT NULL DEFAULT '',
|
||||
period_end TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS password_resets (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
return fmt.Errorf("exec schema: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
94
internal/mail/mail.go
Normal file
94
internal/mail/mail.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds outbound mail settings read from environment variables.
|
||||
type Config struct {
|
||||
Host string // SMTP_HOST
|
||||
Port string // SMTP_PORT
|
||||
User string // SMTP_USER
|
||||
Pass string // SMTP_PASS
|
||||
From string // SMTP_FROM
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads SMTP settings from environment variables.
|
||||
func ConfigFromEnv() Config {
|
||||
port := os.Getenv("SMTP_PORT")
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
return Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: port,
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Pass: os.Getenv("SMTP_PASS"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
}
|
||||
}
|
||||
|
||||
// Ready reports whether all required SMTP fields are set.
|
||||
func (c Config) Ready() bool {
|
||||
return c.Host != "" && c.User != "" && c.Pass != "" && c.From != ""
|
||||
}
|
||||
|
||||
// sendEmail sends a plain-text email to a single recipient.
|
||||
// It tries implicit TLS first, then falls back to STARTTLS via smtp.SendMail.
|
||||
func sendEmail(cfg Config, to, subject, body string) error {
|
||||
addr := cfg.Host + ":" + cfg.Port
|
||||
auth := smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
cfg.From, to, subject, body,
|
||||
)
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
|
||||
if err != nil {
|
||||
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = client.Mail(cfg.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = fmt.Fprint(wc, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return wc.Close()
|
||||
}
|
||||
|
||||
// SendPasswordReset sends a password-reset email containing resetURL.
|
||||
func SendPasswordReset(cfg Config, toEmail, resetURL string) error {
|
||||
subject := "Reset your Arcline IT password"
|
||||
body := fmt.Sprintf(
|
||||
"Hello,\r\n\r\n"+
|
||||
"A password reset was requested for your Arcline IT account.\r\n\r\n"+
|
||||
"Click the link below to set a new password (valid for 1 hour):\r\n\r\n"+
|
||||
"%s\r\n\r\n"+
|
||||
"If you did not request this, you can safely ignore this email.\r\n\r\n"+
|
||||
"-- Arcline IT Support\r\n",
|
||||
resetURL,
|
||||
)
|
||||
return sendEmail(cfg, toEmail, subject, body)
|
||||
}
|
||||
216
internal/payments/payments.go
Normal file
216
internal/payments/payments.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stripelib "github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
stripecustomer "github.com/stripe/stripe-go/v81/customer"
|
||||
"github.com/stripe/stripe-go/v81/subscription"
|
||||
)
|
||||
|
||||
// Config holds Stripe credentials loaded from environment variables.
|
||||
type Config struct {
|
||||
SecretKey string
|
||||
WebhookSecret string
|
||||
PriceIDs map[string]string // plan key → Stripe price ID
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads Stripe settings from environment variables.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
SecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
PriceIDs: map[string]string{
|
||||
"shared_starter": os.Getenv("STRIPE_PRICE_SHARED_STARTER"),
|
||||
"shared_pro": os.Getenv("STRIPE_PRICE_SHARED_PRO"),
|
||||
"shared_business": os.Getenv("STRIPE_PRICE_SHARED_BUSINESS"),
|
||||
"wp_starter": os.Getenv("STRIPE_PRICE_WP_STARTER"),
|
||||
"wp_pro": os.Getenv("STRIPE_PRICE_WP_PRO"),
|
||||
"wp_business": os.Getenv("STRIPE_PRICE_WP_BUSINESS"),
|
||||
"vps_1": os.Getenv("STRIPE_PRICE_VPS_1"),
|
||||
"vps_2": os.Getenv("STRIPE_PRICE_VPS_2"),
|
||||
"vps_3": os.Getenv("STRIPE_PRICE_VPS_3"),
|
||||
"vps_4": os.Getenv("STRIPE_PRICE_VPS_4"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Ready reports whether the required Stripe credentials are set.
|
||||
func (c Config) Ready() bool {
|
||||
return c.SecretKey != "" && c.WebhookSecret != ""
|
||||
}
|
||||
|
||||
// CreateCustomer creates a Stripe customer and returns the Stripe customer ID.
|
||||
func CreateCustomer(email, firstName, lastName string) (string, error) {
|
||||
params := &stripelib.CustomerParams{
|
||||
Email: stripelib.String(email),
|
||||
Name: stripelib.String(firstName + " " + lastName),
|
||||
}
|
||||
c, err := stripecustomer.New(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stripe create customer: %w", err)
|
||||
}
|
||||
return c.ID, nil
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe Checkout session and returns the hosted URL.
|
||||
func CreateCheckoutSession(
|
||||
cfg Config,
|
||||
customerID int64,
|
||||
stripeCustomerID,
|
||||
priceID,
|
||||
successURL,
|
||||
cancelURL string,
|
||||
) (string, error) {
|
||||
params := &stripelib.CheckoutSessionParams{
|
||||
Customer: stripelib.String(stripeCustomerID),
|
||||
Mode: stripelib.String(string(stripelib.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripelib.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripelib.String(priceID),
|
||||
Quantity: stripelib.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripelib.String(successURL),
|
||||
CancelURL: stripelib.String(cancelURL),
|
||||
Metadata: map[string]string{
|
||||
"customer_id": fmt.Sprintf("%d", customerID),
|
||||
},
|
||||
}
|
||||
|
||||
s, err := session.New(params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stripe checkout session: %w", err)
|
||||
}
|
||||
return s.URL, nil
|
||||
}
|
||||
|
||||
// CancelSubscription cancels a Stripe subscription by ID.
|
||||
func CancelSubscription(stripeSubID string) error {
|
||||
params := &stripelib.SubscriptionCancelParams{}
|
||||
_, err := subscription.Cancel(stripeSubID, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe cancel subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleCheckoutCompleted processes a checkout.session.completed webhook event.
|
||||
func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error {
|
||||
var cs stripelib.CheckoutSession
|
||||
if err := json.Unmarshal(raw, &cs); err != nil {
|
||||
return fmt.Errorf("unmarshal checkout session: %w", err)
|
||||
}
|
||||
|
||||
if cs.Subscription == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
customerIDStr, ok := cs.Metadata["customer_id"]
|
||||
if !ok {
|
||||
return fmt.Errorf("missing customer_id in metadata")
|
||||
}
|
||||
|
||||
var customerID int64
|
||||
fmt.Sscanf(customerIDStr, "%d", &customerID)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO subscriptions
|
||||
(customer_id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'active', '', ?, ?)
|
||||
ON CONFLICT(stripe_subscription_id) DO UPDATE SET
|
||||
status = 'active',
|
||||
updated_at = excluded.updated_at`,
|
||||
customerID, cs.Subscription.ID, "", "", now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// HandleInvoicePaid processes an invoice.paid webhook event.
|
||||
func HandleInvoicePaid(db *sql.DB, raw json.RawMessage) error {
|
||||
var inv stripelib.Invoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return fmt.Errorf("unmarshal invoice: %w", err)
|
||||
}
|
||||
|
||||
var customerID int64
|
||||
if inv.Customer != nil {
|
||||
err := db.QueryRow(
|
||||
`SELECT id FROM customers WHERE stripe_customer_id = ?`, inv.Customer.ID,
|
||||
).Scan(&customerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find customer for stripe id %s: %w", inv.Customer.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
pdfURL := ""
|
||||
if inv.InvoicePDF != "" {
|
||||
pdfURL = inv.InvoicePDF
|
||||
}
|
||||
|
||||
periodStart := time.Unix(inv.PeriodStart, 0).UTC().Format(time.RFC3339)
|
||||
periodEnd := time.Unix(inv.PeriodEnd, 0).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO invoices
|
||||
(customer_id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url, period_start, period_end, created_at)
|
||||
VALUES (?, ?, ?, ?, 'paid', ?, ?, ?, ?)
|
||||
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
|
||||
status = 'paid',
|
||||
invoice_pdf_url = excluded.invoice_pdf_url`,
|
||||
customerID, inv.ID, inv.AmountPaid, string(inv.Currency), pdfURL, periodStart, periodEnd, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert invoice: %w", err)
|
||||
}
|
||||
|
||||
if inv.Subscription != nil {
|
||||
_, _ = db.Exec(
|
||||
`UPDATE subscriptions SET current_period_end = ?, updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
periodEnd, now, inv.Subscription.ID,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleInvoicePaymentFailed processes an invoice.payment_failed webhook event.
|
||||
func HandleInvoicePaymentFailed(db *sql.DB, raw json.RawMessage) error {
|
||||
var inv stripelib.Invoice
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return fmt.Errorf("unmarshal invoice: %w", err)
|
||||
}
|
||||
|
||||
if inv.Subscription == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
`UPDATE subscriptions SET status = 'past_due', updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
now, inv.Subscription.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// HandleSubscriptionDeleted processes a customer.subscription.deleted webhook event.
|
||||
func HandleSubscriptionDeleted(db *sql.DB, raw json.RawMessage) error {
|
||||
var sub stripelib.Subscription
|
||||
if err := json.Unmarshal(raw, &sub); err != nil {
|
||||
return fmt.Errorf("unmarshal subscription: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
now, sub.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
652
internal/web/handler.go
Normal file
652
internal/web/handler.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v81/webhook"
|
||||
|
||||
"arclineit.com/billing/internal/auth"
|
||||
"arclineit.com/billing/internal/mail"
|
||||
"arclineit.com/billing/internal/payments"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
// Handler holds all HTTP handler dependencies.
|
||||
type Handler struct {
|
||||
DB *sql.DB
|
||||
Stripe payments.Config
|
||||
SMTP mail.Config
|
||||
BaseURL string
|
||||
ts *templateSet
|
||||
}
|
||||
|
||||
// New creates a Handler and pre-parses all HTML templates.
|
||||
func New(db *sql.DB, stripe payments.Config, smtp mail.Config, baseURL string) (*Handler, error) {
|
||||
ts, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{DB: db, Stripe: stripe, SMTP: smtp, BaseURL: baseURL, ts: ts}, nil
|
||||
}
|
||||
|
||||
// ---- template set ----
|
||||
|
||||
type templateSet struct {
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
var templateFuncs = template.FuncMap{
|
||||
"slice": func(start, end int, s string) string {
|
||||
runes := []rune(s)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
if start > end {
|
||||
return ""
|
||||
}
|
||||
return string(runes[start:end])
|
||||
},
|
||||
"fmtDate": func(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return t.Format("Jan 2, 2006")
|
||||
},
|
||||
}
|
||||
|
||||
func loadTemplates() (*templateSet, error) {
|
||||
tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &templateSet{tmpl: tmpl}, nil
|
||||
}
|
||||
|
||||
func (ts *templateSet) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ts.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
slog.Error("template render", "name", name, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- data models ----
|
||||
|
||||
type customer struct {
|
||||
ID int64
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
StripeCustomerID string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type subscriptionRow struct {
|
||||
ID int64
|
||||
StripeSubscriptionID string
|
||||
StripePriceID string
|
||||
PlanName string
|
||||
Status string
|
||||
CurrentPeriodEnd string
|
||||
}
|
||||
|
||||
type invoiceRow struct {
|
||||
ID int64
|
||||
StripeInvoiceID string
|
||||
AmountCents int64
|
||||
Currency string
|
||||
Status string
|
||||
InvoicePDFURL string
|
||||
PeriodStart string
|
||||
PeriodEnd string
|
||||
CreatedAt string
|
||||
AmountDisplay string
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
Customer customer
|
||||
Subscription *subscriptionRow
|
||||
Invoices []invoiceRow
|
||||
Flash string
|
||||
}
|
||||
|
||||
// ---- DB helpers ----
|
||||
|
||||
func loadCustomer(db *sql.DB, id int64) (customer, error) {
|
||||
var c customer
|
||||
err := db.QueryRow(
|
||||
`SELECT id, email, first_name, last_name, stripe_customer_id, created_at FROM customers WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName, &c.StripeCustomerID, &c.CreatedAt)
|
||||
return c, err
|
||||
}
|
||||
|
||||
func loadSubscription(db *sql.DB, customerID int64) (*subscriptionRow, error) {
|
||||
var s subscriptionRow
|
||||
err := db.QueryRow(
|
||||
`SELECT id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end
|
||||
FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1`,
|
||||
customerID,
|
||||
).Scan(&s.ID, &s.StripeSubscriptionID, &s.StripePriceID, &s.PlanName, &s.Status, &s.CurrentPeriodEnd)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
func loadRecentInvoices(db *sql.DB, customerID int64) ([]invoiceRow, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url,
|
||||
period_start, period_end, created_at
|
||||
FROM invoices WHERE customer_id = ? ORDER BY created_at DESC LIMIT 5`,
|
||||
customerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []invoiceRow
|
||||
for rows.Next() {
|
||||
var inv invoiceRow
|
||||
if err := rows.Scan(
|
||||
&inv.ID, &inv.StripeInvoiceID, &inv.AmountCents, &inv.Currency,
|
||||
&inv.Status, &inv.InvoicePDFURL, &inv.PeriodStart, &inv.PeriodEnd, &inv.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dollars := inv.AmountCents / 100
|
||||
cents := inv.AmountCents % 100
|
||||
inv.AmountDisplay = formatCurrency(dollars, cents, strings.ToUpper(inv.Currency))
|
||||
result = append(result, inv)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func formatCurrency(dollars, cents int64, currency string) string {
|
||||
if currency == "USD" || currency == "" {
|
||||
return "$" + itoa(dollars) + "." + pad2(cents)
|
||||
}
|
||||
return itoa(dollars) + "." + pad2(cents) + " " + currency
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
s := ""
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
s = string(rune('0'+n%10)) + s
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
s = "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func pad2(n int64) string {
|
||||
if n < 10 {
|
||||
return "0" + itoa(n)
|
||||
}
|
||||
return itoa(n)
|
||||
}
|
||||
|
||||
// ---- session cookie helpers ----
|
||||
|
||||
func sessionSecure() bool {
|
||||
return os.Getenv("SESSION_SECURE") != "false"
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(auth.SessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: sessionSecure(),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: sessionSecure(),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- route handlers ----
|
||||
|
||||
func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_, err = auth.GetSession(h.DB, cookie.Value)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.ts.render(w, "login.html", map[string]any{
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
http.Redirect(w, r, "/login?error=missing_fields", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var id int64
|
||||
var hash string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, password_hash FROM customers WHERE email = ?`, email,
|
||||
).Scan(&id, &hash)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) || !auth.CheckPassword(hash, password) {
|
||||
http.Redirect(w, r, "/login?error=invalid_credentials", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("login: db query", "err", err)
|
||||
http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.CreateSession(h.DB, id)
|
||||
if err != nil {
|
||||
slog.Error("login: create session", "err", err)
|
||||
http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
setSessionCookie(w, token)
|
||||
slog.Info("customer logged in", "customer_id", id, "email", email)
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.ts.render(w, "register.html", map[string]any{
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterPOST(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
firstName := strings.TrimSpace(r.FormValue("first_name"))
|
||||
lastName := strings.TrimSpace(r.FormValue("last_name"))
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
if firstName == "" || lastName == "" || email == "" || password == "" {
|
||||
http.Redirect(w, r, "/register?error=missing_fields", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
http.Redirect(w, r, "/register?error=password_mismatch", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
http.Redirect(w, r, "/register?error=password_too_short", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var existing int64
|
||||
_ = h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&existing)
|
||||
if existing != 0 {
|
||||
http.Redirect(w, r, "/register?error=email_taken", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
slog.Error("register: hash password", "err", err)
|
||||
http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
stripeCustomerID := ""
|
||||
if h.Stripe.Ready() {
|
||||
stripeCustomerID, err = payments.CreateCustomer(email, firstName, lastName)
|
||||
if err != nil {
|
||||
slog.Error("register: create stripe customer", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := h.DB.Exec(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, stripe_customer_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
email, hash, firstName, lastName, stripeCustomerID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("register: insert customer", "err", err)
|
||||
http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
customerID, _ := res.LastInsertId()
|
||||
|
||||
token, err := auth.CreateSession(h.DB, customerID)
|
||||
if err != nil {
|
||||
slog.Error("register: create session", "err", err)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
setSessionCookie(w, token)
|
||||
slog.Info("customer registered", "customer_id", customerID, "email", email)
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := auth.CustomerIDFromContext(r.Context())
|
||||
|
||||
c, err := loadCustomer(h.DB, customerID)
|
||||
if err != nil {
|
||||
slog.Error("dashboard: load customer", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := loadSubscription(h.DB, customerID)
|
||||
if err != nil {
|
||||
slog.Error("dashboard: load subscription", "err", err)
|
||||
}
|
||||
|
||||
invoices, err := loadRecentInvoices(h.DB, customerID)
|
||||
if err != nil {
|
||||
slog.Error("dashboard: load invoices", "err", err)
|
||||
}
|
||||
|
||||
flash := ""
|
||||
switch r.URL.Query().Get("checkout") {
|
||||
case "success":
|
||||
flash = "Your subscription is being activated. It may take a moment to appear."
|
||||
case "cancelled":
|
||||
flash = "Checkout was cancelled. No charge was made."
|
||||
}
|
||||
if r.URL.Query().Get("cancelled") == "1" {
|
||||
flash = "Your subscription has been cancelled."
|
||||
}
|
||||
if r.URL.Query().Get("error") == "cancel_failed" {
|
||||
flash = "Could not cancel subscription. Please contact support."
|
||||
}
|
||||
|
||||
h.ts.render(w, "dashboard.html", dashboardData{
|
||||
Customer: c,
|
||||
Subscription: sub,
|
||||
Invoices: invoices,
|
||||
Flash: flash,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
_ = auth.DeleteSession(h.DB, cookie.Value)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.ts.render(w, "reset-request.html", map[string]any{
|
||||
"Sent": r.URL.Query().Get("sent"),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
if email == "" {
|
||||
http.Redirect(w, r, "/reset?error=missing_email", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var customerID int64
|
||||
err := h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&customerID)
|
||||
|
||||
if err == nil {
|
||||
token, tokenErr := auth.CreateResetToken(h.DB, customerID)
|
||||
if tokenErr != nil {
|
||||
slog.Error("reset: create token", "err", tokenErr)
|
||||
} else if h.SMTP.Ready() {
|
||||
resetURL := h.BaseURL + "/reset/" + token
|
||||
if mailErr := mail.SendPasswordReset(h.SMTP, email, resetURL); mailErr != nil {
|
||||
slog.Error("reset: send email", "err", mailErr)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("reset: SMTP not configured, reset token not sent", "email", email)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetConfirmGET(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
h.ts.render(w, "reset-confirm.html", map[string]any{
|
||||
"Token": token,
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetConfirmPOST(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
if password == "" {
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=missing_password", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=password_mismatch", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=password_too_short", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
customerID, err := auth.ValidateResetToken(h.DB, token)
|
||||
if err != nil {
|
||||
slog.Warn("reset confirm: invalid token", "err", err)
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=invalid_token", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
slog.Error("reset confirm: hash password", "err", err)
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.DB.Exec(
|
||||
`UPDATE customers SET password_hash = ? WHERE id = ?`, hash, customerID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reset confirm: update password", "err", err)
|
||||
http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.ConsumeResetToken(h.DB, token); err != nil {
|
||||
slog.Error("reset confirm: consume token", "err", err)
|
||||
}
|
||||
|
||||
_, _ = h.DB.Exec(`DELETE FROM sessions WHERE customer_id = ?`, customerID)
|
||||
|
||||
slog.Info("password reset completed", "customer_id", customerID)
|
||||
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) CheckoutGET(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := auth.CustomerIDFromContext(r.Context())
|
||||
priceID := r.URL.Query().Get("plan")
|
||||
if priceID == "" {
|
||||
http.Error(w, "missing plan parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var stripeCustomerID string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT stripe_customer_id FROM customers WHERE id = ?`, customerID,
|
||||
).Scan(&stripeCustomerID)
|
||||
if err != nil {
|
||||
slog.Error("checkout: load customer", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
successURL := h.BaseURL + "/dashboard?checkout=success"
|
||||
cancelURL := h.BaseURL + "/dashboard?checkout=cancelled"
|
||||
|
||||
url, err := payments.CreateCheckoutSession(h.Stripe, customerID, stripeCustomerID, priceID, successURL, cancelURL)
|
||||
if err != nil {
|
||||
slog.Error("checkout: create session", "err", err)
|
||||
http.Error(w, "could not create checkout session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) WebhookPOST(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBodyBytes = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
event, err := webhook.ConstructEvent(payload, sig, h.Stripe.WebhookSecret)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: signature verification failed", "err", err)
|
||||
http.Error(w, "invalid signature", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("webhook received", "type", event.Type)
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
if err := payments.HandleCheckoutCompleted(h.DB, event.Data.Raw); err != nil {
|
||||
slog.Error("webhook: checkout.session.completed", "err", err)
|
||||
}
|
||||
case "invoice.paid":
|
||||
if err := payments.HandleInvoicePaid(h.DB, event.Data.Raw); err != nil {
|
||||
slog.Error("webhook: invoice.paid", "err", err)
|
||||
}
|
||||
case "invoice.payment_failed":
|
||||
if err := payments.HandleInvoicePaymentFailed(h.DB, event.Data.Raw); err != nil {
|
||||
slog.Error("webhook: invoice.payment_failed", "err", err)
|
||||
}
|
||||
case "customer.subscription.deleted":
|
||||
if err := payments.HandleSubscriptionDeleted(h.DB, event.Data.Raw); err != nil {
|
||||
slog.Error("webhook: customer.subscription.deleted", "err", err)
|
||||
}
|
||||
default:
|
||||
slog.Info("webhook: unhandled event type", "type", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) CancelPOST(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := auth.CustomerIDFromContext(r.Context())
|
||||
|
||||
var stripeSubID string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT stripe_subscription_id FROM subscriptions
|
||||
WHERE customer_id = ? AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
customerID,
|
||||
).Scan(&stripeSubID)
|
||||
if err != nil {
|
||||
slog.Error("cancel: find subscription", "err", err)
|
||||
http.Redirect(w, r, "/dashboard?error=no_subscription", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := payments.CancelSubscription(stripeSubID); err != nil {
|
||||
slog.Error("cancel: stripe cancel", "err", err)
|
||||
http.Redirect(w, r, "/dashboard?error=cancel_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, _ = h.DB.Exec(
|
||||
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
now, stripeSubID,
|
||||
)
|
||||
|
||||
slog.Info("subscription cancelled", "customer_id", customerID, "stripe_sub_id", stripeSubID)
|
||||
http.Redirect(w, r, "/dashboard?cancelled=1", http.StatusSeeOther)
|
||||
}
|
||||
150
internal/web/middleware.go
Normal file
150
internal/web/middleware.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// securityHeaders are applied to every response.
|
||||
var securityHeaders = map[string]string{
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
"Content-Security-Policy": "default-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"font-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'",
|
||||
}
|
||||
|
||||
func applySecurityHeaders(w http.ResponseWriter) {
|
||||
for k, v := range securityHeaders {
|
||||
if w.Header().Get(k) == "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-IP rate limiter ----
|
||||
|
||||
type ipLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// RateLimiter tracks per-IP request rates.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*ipLimiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a RateLimiter with the given sustained rate and burst.
|
||||
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
limiters: make(map[string]*ipLimiter),
|
||||
r: r,
|
||||
burst: burst,
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) get(ip string) *rate.Limiter {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
entry, ok := rl.limiters[ip]
|
||||
if !ok {
|
||||
entry = &ipLimiter{limiter: rate.NewLimiter(rl.r, rl.burst)}
|
||||
rl.limiters[ip] = entry
|
||||
}
|
||||
entry.lastSeen = time.Now()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
for ip, e := range rl.limiters {
|
||||
if time.Since(e.lastSeen) > 10*time.Minute {
|
||||
delete(rl.limiters, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- status recorder for logging ----
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (sr *statusRecorder) WriteHeader(code int) {
|
||||
if !sr.wroteHeader {
|
||||
sr.status = code
|
||||
sr.wroteHeader = true
|
||||
}
|
||||
sr.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (sr *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !sr.wroteHeader {
|
||||
sr.status = http.StatusOK
|
||||
sr.wroteHeader = true
|
||||
}
|
||||
return sr.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// BuildMiddleware wraps mux with: logging → timeout → rate-limit → security headers.
|
||||
func BuildMiddleware(mux http.Handler, rl *RateLimiter, timeout time.Duration) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
if !rl.get(ip).Allow() {
|
||||
http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
|
||||
slog.Info("rate limited", "ip", ip, "path", r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
applySecurityHeaders(w)
|
||||
|
||||
sr := &statusRecorder{ResponseWriter: w}
|
||||
mux.ServeHTTP(sr, r)
|
||||
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sr.status,
|
||||
"ip", ip,
|
||||
"duration", time.Since(start).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
39
internal/web/templates/base.html
Normal file
39
internal/web/templates/base.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}Arcline IT — Client Portal{{end}}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<div class="nav__inner">
|
||||
<a href="/" class="nav__logo">
|
||||
<span class="nav__logo-bracket">[</span>arcline<span class="nav__logo-accent">IT</span><span class="nav__logo-bracket">]</span>
|
||||
</a>
|
||||
<span class="nav__label">client portal</span>
|
||||
<div class="nav__actions">
|
||||
{{block "nav-actions" .}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer__inner">
|
||||
<span class="footer__copy">© 2026 Arcline IT</span>
|
||||
<span class="footer__sep">·</span>
|
||||
<a href="https://arclineit.com/tos" class="footer__link">Terms</a>
|
||||
<span class="footer__sep">·</span>
|
||||
<a href="https://arclineit.com/privacy" class="footer__link">Privacy</a>
|
||||
<span class="footer__sep">·</span>
|
||||
<a href="https://arclineit.com/contact" class="footer__link">Support</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
144
internal/web/templates/dashboard.html
Normal file
144
internal/web/templates/dashboard.html
Normal file
@@ -0,0 +1,144 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Dashboard — Arcline IT Client Portal{{end}}
|
||||
|
||||
{{define "nav-actions"}}
|
||||
<form method="POST" action="/logout" style="display:inline;">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">logout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="dashboard">
|
||||
<div class="container">
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert alert--ok flash">{{.Flash}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="dash-header">
|
||||
<h1 class="dash-header__title">
|
||||
Welcome back, <span class="dash-header__name">{{.Customer.FirstName}}</span><span class="cursor"></span>
|
||||
</h1>
|
||||
<p class="dash-header__email">{{.Customer.Email}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription panel -->
|
||||
<section class="dash-section">
|
||||
<h2 class="dash-section__title">// subscription</h2>
|
||||
|
||||
{{if .Subscription}}
|
||||
<div class="term-window">
|
||||
<div class="term-window__chrome">
|
||||
<span class="term-window__dot term-window__dot--red"></span>
|
||||
<span class="term-window__dot term-window__dot--yellow"></span>
|
||||
<span class="term-window__dot term-window__dot--green"></span>
|
||||
<span class="term-window__title">subscription.status</span>
|
||||
</div>
|
||||
<div class="term-window__body">
|
||||
<div class="status-row">
|
||||
<span class="status-row__label">plan</span>
|
||||
<span class="status-row__dots"></span>
|
||||
<span class="status-row__value">{{if .Subscription.PlanName}}{{.Subscription.PlanName}}{{else}}active plan{{end}}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-row__label">status</span>
|
||||
<span class="status-row__dots"></span>
|
||||
<span class="status-badge status-badge--{{.Subscription.Status}}">{{.Subscription.Status}}</span>
|
||||
</div>
|
||||
{{if .Subscription.CurrentPeriodEnd}}
|
||||
<div class="status-row">
|
||||
<span class="status-row__label">next billing date</span>
|
||||
<span class="status-row__dots"></span>
|
||||
<span class="status-row__value">{{.Subscription.CurrentPeriodEnd}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if eq .Subscription.Status "active"}}
|
||||
<div class="dash-actions">
|
||||
<form method="POST" action="/cancel"
|
||||
onsubmit="return confirm('Are you sure you want to cancel your subscription? This cannot be undone.')">
|
||||
<button class="btn btn--danger btn--sm" type="submit">cancel subscription</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{else}}
|
||||
<div class="term-window">
|
||||
<div class="term-window__chrome">
|
||||
<span class="term-window__dot term-window__dot--red"></span>
|
||||
<span class="term-window__dot term-window__dot--yellow"></span>
|
||||
<span class="term-window__dot term-window__dot--green"></span>
|
||||
<span class="term-window__title">subscription.status</span>
|
||||
</div>
|
||||
<div class="term-window__body">
|
||||
<p class="dash-empty">No active subscription.</p>
|
||||
<p class="dash-empty-sub">
|
||||
<a href="https://arclineit.com/pricing" class="link">View plans</a> to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Invoices panel -->
|
||||
<section class="dash-section">
|
||||
<h2 class="dash-section__title">// recent invoices</h2>
|
||||
|
||||
{{if .Invoices}}
|
||||
<div class="term-window">
|
||||
<div class="term-window__chrome">
|
||||
<span class="term-window__dot term-window__dot--red"></span>
|
||||
<span class="term-window__dot term-window__dot--yellow"></span>
|
||||
<span class="term-window__dot term-window__dot--green"></span>
|
||||
<span class="term-window__title">invoices</span>
|
||||
</div>
|
||||
<div class="term-window__body">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="inv-table__th">date</th>
|
||||
<th class="inv-table__th">period</th>
|
||||
<th class="inv-table__th inv-table__th--right">amount</th>
|
||||
<th class="inv-table__th">status</th>
|
||||
<th class="inv-table__th">pdf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Invoices}}
|
||||
<tr class="inv-table__row">
|
||||
<td class="inv-table__td inv-table__td--muted">{{.CreatedAt | slice 0 10}}</td>
|
||||
<td class="inv-table__td inv-table__td--muted">{{.PeriodStart | slice 0 10}} – {{.PeriodEnd | slice 0 10}}</td>
|
||||
<td class="inv-table__td inv-table__td--right">{{.AmountDisplay}}</td>
|
||||
<td class="inv-table__td"><span class="status-badge status-badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="inv-table__td">
|
||||
{{if .InvoicePDFURL}}
|
||||
<a href="{{.InvoicePDFURL}}" class="link link--sm" target="_blank" rel="noopener">download</a>
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="term-window">
|
||||
<div class="term-window__chrome">
|
||||
<span class="term-window__dot term-window__dot--red"></span>
|
||||
<span class="term-window__dot term-window__dot--yellow"></span>
|
||||
<span class="term-window__dot term-window__dot--green"></span>
|
||||
<span class="term-window__title">invoices</span>
|
||||
</div>
|
||||
<div class="term-window__body">
|
||||
<p class="dash-empty">No invoices yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
49
internal/web/templates/login.html
Normal file
49
internal/web/templates/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Sign In — Arcline IT Client Portal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__header">
|
||||
<span class="auth-card__prompt">$ arcline-billing --login</span>
|
||||
</div>
|
||||
<div class="auth-card__body">
|
||||
<h1 class="auth-card__title">Sign in to your account</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert--error">
|
||||
{{if eq .Error "invalid_credentials"}}Invalid email or password.
|
||||
{{else if eq .Error "missing_fields"}}Please fill in all fields.
|
||||
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
|
||||
{{else}}An error occurred. Please try again.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq (index . "reset") "1"}}
|
||||
<div class="alert alert--ok">Password updated. You can now sign in.</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="email">Email</label>
|
||||
<input class="form__input" type="email" id="email" name="email"
|
||||
autocomplete="email" required placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="password">Password</label>
|
||||
<input class="form__input" type="password" id="password" name="password"
|
||||
autocomplete="current-password" required placeholder="••••••••">
|
||||
</div>
|
||||
<button class="btn btn--primary btn--full" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__links">
|
||||
<a href="/reset" class="auth-card__link">Forgot password?</a>
|
||||
<span class="auth-card__sep">·</span>
|
||||
<a href="/register" class="auth-card__link">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
63
internal/web/templates/register.html
Normal file
63
internal/web/templates/register.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Create Account — Arcline IT Client Portal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__header">
|
||||
<span class="auth-card__prompt">$ arcline-billing --register</span>
|
||||
</div>
|
||||
<div class="auth-card__body">
|
||||
<h1 class="auth-card__title">Create your account</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert--error">
|
||||
{{if eq .Error "missing_fields"}}Please fill in all required fields.
|
||||
{{else if eq .Error "password_mismatch"}}Passwords do not match.
|
||||
{{else if eq .Error "password_too_short"}}Password must be at least 8 characters.
|
||||
{{else if eq .Error "email_taken"}}An account with that email already exists.
|
||||
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
|
||||
{{else}}An error occurred. Please try again.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/register" class="form">
|
||||
<div class="form__row">
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="first_name">First name</label>
|
||||
<input class="form__input" type="text" id="first_name" name="first_name"
|
||||
autocomplete="given-name" required placeholder="Blake">
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="last_name">Last name</label>
|
||||
<input class="form__input" type="text" id="last_name" name="last_name"
|
||||
autocomplete="family-name" required placeholder="Smith">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="email">Email</label>
|
||||
<input class="form__input" type="email" id="email" name="email"
|
||||
autocomplete="email" required placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="password">Password <span class="form__hint">(min. 8 characters)</span></label>
|
||||
<input class="form__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" required placeholder="••••••••" minlength="8">
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="confirm_password">Confirm password</label>
|
||||
<input class="form__input" type="password" id="confirm_password" name="confirm_password"
|
||||
autocomplete="new-password" required placeholder="••••••••" minlength="8">
|
||||
</div>
|
||||
<button class="btn btn--primary btn--full" type="submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__links">
|
||||
<span class="auth-card__text">Already have an account?</span>
|
||||
<a href="/login" class="auth-card__link">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
45
internal/web/templates/reset-confirm.html
Normal file
45
internal/web/templates/reset-confirm.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Set New Password — Arcline IT Client Portal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__header">
|
||||
<span class="auth-card__prompt">$ arcline-billing --set-password</span>
|
||||
</div>
|
||||
<div class="auth-card__body">
|
||||
<h1 class="auth-card__title">Set a new password</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert--error">
|
||||
{{if eq .Error "invalid_token"}}This reset link is invalid or has expired.
|
||||
{{else if eq .Error "missing_password"}}Please enter a new password.
|
||||
{{else if eq .Error "password_mismatch"}}Passwords do not match.
|
||||
{{else if eq .Error "password_too_short"}}Password must be at least 8 characters.
|
||||
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
|
||||
{{else}}An error occurred. Please try again.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/reset/{{.Token}}" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="password">New password <span class="form__hint">(min. 8 characters)</span></label>
|
||||
<input class="form__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" required placeholder="••••••••" minlength="8">
|
||||
</div>
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="confirm_password">Confirm new password</label>
|
||||
<input class="form__input" type="password" id="confirm_password" name="confirm_password"
|
||||
autocomplete="new-password" required placeholder="••••••••" minlength="8">
|
||||
</div>
|
||||
<button class="btn btn--primary btn--full" type="submit">Update password</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__links">
|
||||
<a href="/login" class="auth-card__link">Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
48
internal/web/templates/reset-request.html
Normal file
48
internal/web/templates/reset-request.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Reset Password — Arcline IT Client Portal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__header">
|
||||
<span class="auth-card__prompt">$ arcline-billing --reset-password</span>
|
||||
</div>
|
||||
<div class="auth-card__body">
|
||||
<h1 class="auth-card__title">Reset your password</h1>
|
||||
|
||||
{{if .Sent}}
|
||||
<div class="alert alert--ok">
|
||||
If an account exists for that email, a reset link has been sent. Check your inbox.
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
{{if .Error}}
|
||||
<div class="alert alert--error">
|
||||
{{if eq .Error "missing_email"}}Please enter your email address.
|
||||
{{else}}An error occurred. Please try again.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="auth-card__desc">
|
||||
Enter your account email and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/reset" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="email">Email</label>
|
||||
<input class="form__input" type="email" id="email" name="email"
|
||||
autocomplete="email" required placeholder="you@example.com">
|
||||
</div>
|
||||
<button class="btn btn--primary btn--full" type="submit">Send reset link</button>
|
||||
</form>
|
||||
|
||||
{{end}}
|
||||
|
||||
<div class="auth-card__links">
|
||||
<a href="/login" class="auth-card__link">Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
135
main.go
Normal file
135
main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
stripelib "github.com/stripe/stripe-go/v81"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"arclineit.com/billing/internal/auth"
|
||||
"arclineit.com/billing/internal/db"
|
||||
"arclineit.com/billing/internal/mail"
|
||||
"arclineit.com/billing/internal/payments"
|
||||
"arclineit.com/billing/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
slog.Info(".env not found, using system environment")
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8082"
|
||||
}
|
||||
|
||||
baseURL := os.Getenv("BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:" + port
|
||||
}
|
||||
|
||||
dbPath := os.Getenv("DATABASE_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./arcline-billing.db"
|
||||
}
|
||||
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to open database", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
smtpCfg := mail.ConfigFromEnv()
|
||||
if !smtpCfg.Ready() {
|
||||
slog.Warn("SMTP not configured: password reset emails will not be sent. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM.")
|
||||
}
|
||||
|
||||
stripeCfg := payments.ConfigFromEnv()
|
||||
if !stripeCfg.Ready() {
|
||||
slog.Warn("Stripe not configured: payments will not work. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.")
|
||||
} else {
|
||||
stripelib.Key = stripeCfg.SecretKey
|
||||
}
|
||||
|
||||
h, err := web.New(database, stripeCfg, smtpCfg, baseURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to init handlers", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static assets.
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// Public routes.
|
||||
mux.HandleFunc("GET /{$}", h.IndexHandler)
|
||||
mux.HandleFunc("GET /login", h.LoginGET)
|
||||
mux.HandleFunc("POST /login", h.LoginPOST)
|
||||
mux.HandleFunc("GET /register", h.RegisterGET)
|
||||
mux.HandleFunc("POST /register", h.RegisterPOST)
|
||||
mux.HandleFunc("POST /logout", h.LogoutPOST)
|
||||
mux.HandleFunc("GET /reset", h.ResetGET)
|
||||
mux.HandleFunc("POST /reset", h.ResetPOST)
|
||||
mux.HandleFunc("GET /reset/{token}", h.ResetConfirmGET)
|
||||
mux.HandleFunc("POST /reset/{token}", h.ResetConfirmPOST)
|
||||
|
||||
// Stripe webhook — no auth (Stripe signs the payload instead).
|
||||
mux.HandleFunc("POST /webhook", h.WebhookPOST)
|
||||
|
||||
// Authenticated routes.
|
||||
authed := auth.Middleware(database)
|
||||
mux.Handle("GET /dashboard", authed(http.HandlerFunc(h.DashboardGET)))
|
||||
mux.Handle("GET /checkout", authed(http.HandlerFunc(h.CheckoutGET)))
|
||||
mux.Handle("POST /cancel", authed(http.HandlerFunc(h.CancelPOST)))
|
||||
|
||||
// Rate limiter: 60 req/s sustained, burst 120.
|
||||
rl := web.NewRateLimiter(rate.Limit(60), 120)
|
||||
handler := web.BuildMiddleware(mux, rl, 10*time.Second)
|
||||
|
||||
addr := "0.0.0.0:" + port
|
||||
slog.Info("arcline-billing listening", "addr", "http://"+addr)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
stop()
|
||||
slog.Info("shutting down")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("shutdown error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("shutdown complete")
|
||||
}
|
||||
502
static/css/style.css
Normal file
502
static/css/style.css
Normal file
@@ -0,0 +1,502 @@
|
||||
/* ============================================================
|
||||
ARCLINE BILLING — Terminal / Monospace Design System
|
||||
Matches arcline website design tokens.
|
||||
============================================================ */
|
||||
|
||||
/* --- Reset --- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; font-size: 16px; -webkit-text-size-adjust: 100%; }
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
ul, ol { list-style: none; }
|
||||
button { cursor: pointer; border: none; background: none; font: inherit; }
|
||||
input, textarea, select { font: inherit; }
|
||||
|
||||
/* --- Design Tokens --- */
|
||||
:root {
|
||||
--bg: #060b10;
|
||||
--surface: #0c1319;
|
||||
--surface-2: #121c25;
|
||||
--border: #1c2a34;
|
||||
--border-bright: #27394a;
|
||||
--border-dim: #111c24;
|
||||
|
||||
--cyan: #00c8f0;
|
||||
--cyan-dim: #0090b8;
|
||||
--cyan-bg: rgba(0, 200, 240, 0.06);
|
||||
--cyan-border: rgba(0, 200, 240, 0.2);
|
||||
|
||||
--text: #b8cdd8;
|
||||
--text-dim: #456070;
|
||||
--text-bright: #e0eff8;
|
||||
--text-code: #7ab8d0;
|
||||
|
||||
--ok: #00c8f0;
|
||||
--warn: #f0a020;
|
||||
--err: #e05050;
|
||||
--ok-bg: rgba(0, 200, 240, 0.08);
|
||||
--err-bg: rgba(224, 80, 80, 0.10);
|
||||
--warn-bg: rgba(240, 160, 32, 0.10);
|
||||
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
|
||||
--font-size-xs: 0.6875rem;
|
||||
--font-size-sm: 0.75rem;
|
||||
--font-size-md: 0.875rem;
|
||||
--font-size-base: 0.9375rem;
|
||||
--font-size-lg: 1.0625rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.75rem;
|
||||
|
||||
--radius: 2px;
|
||||
--nav-h: 56px;
|
||||
--max-w: 1100px;
|
||||
--t: 0.15s ease;
|
||||
}
|
||||
|
||||
/* --- Base --- */
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.main { flex: 1; }
|
||||
|
||||
.container {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
@media (max-width: 640px) { .container { padding: 0 1.25rem; } }
|
||||
|
||||
/* --- Blinking cursor --- */
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
.cursor::after {
|
||||
content: '▋';
|
||||
color: var(--cyan);
|
||||
animation: blink 1s step-end infinite;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NAVIGATION
|
||||
============================================================ */
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: var(--nav-h);
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav__inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav__logo {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav__logo-bracket { color: var(--text-dim); }
|
||||
.nav__logo-accent { color: var(--cyan); }
|
||||
|
||||
.nav__label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FOOTER
|
||||
============================================================ */
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1.25rem 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer__sep { color: var(--border-bright); }
|
||||
.footer__link { color: var(--text-dim); transition: color var(--t); }
|
||||
.footer__link:hover { color: var(--cyan); }
|
||||
|
||||
/* ============================================================
|
||||
BUTTONS
|
||||
============================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.55em 1.2em;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--t), color var(--t), border-color var(--t);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--cyan);
|
||||
color: var(--bg);
|
||||
border: 1px solid var(--cyan);
|
||||
}
|
||||
.btn--primary:hover { background: var(--cyan-dim); border-color: var(--cyan-dim); }
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--cyan);
|
||||
border: 1px solid var(--cyan-border);
|
||||
}
|
||||
.btn--ghost:hover { background: var(--cyan-bg); border-color: var(--cyan); }
|
||||
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
color: var(--err);
|
||||
border: 1px solid rgba(224, 80, 80, 0.35);
|
||||
}
|
||||
.btn--danger:hover { background: var(--err-bg); border-color: var(--err); }
|
||||
|
||||
.btn--muted {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn--muted:hover { color: var(--text); border-color: var(--border-bright); }
|
||||
|
||||
.btn--full { width: 100%; }
|
||||
.btn--sm { font-size: var(--font-size-sm); padding: 0.35em 0.9em; }
|
||||
|
||||
/* ============================================================
|
||||
ALERTS
|
||||
============================================================ */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-md);
|
||||
margin-bottom: 1.25rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
.alert--ok { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); }
|
||||
.alert--error { background: var(--err-bg); border-color: var(--err); color: var(--err); }
|
||||
.alert--warn { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
|
||||
|
||||
.flash { margin-bottom: 1.5rem; }
|
||||
|
||||
/* ============================================================
|
||||
AUTH CARD (login / register / reset)
|
||||
============================================================ */
|
||||
.auth-wrap {
|
||||
min-height: calc(100vh - var(--nav-h) - 60px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1.25rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-card__header {
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.6rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-card__prompt {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--cyan);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.auth-card__body { padding: 2rem; }
|
||||
|
||||
.auth-card__title {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card__desc {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-card__links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-card__link { color: var(--cyan); transition: color var(--t); }
|
||||
.auth-card__link:hover { color: #fff; }
|
||||
.auth-card__sep { color: var(--border-bright); }
|
||||
.auth-card__text { color: var(--text-dim); }
|
||||
|
||||
/* ============================================================
|
||||
FORMS
|
||||
============================================================ */
|
||||
.form { display: flex; flex-direction: column; gap: 1.1rem; }
|
||||
|
||||
.form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 420px) { .form__row { grid-template-columns: 1fr; } }
|
||||
|
||||
.form__group { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
|
||||
.form__label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.form__hint { color: var(--text-dim); font-size: 0.75em; }
|
||||
|
||||
.form__input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-bright);
|
||||
padding: 0.6em 0.85em;
|
||||
font-size: var(--font-size-base);
|
||||
transition: border-color var(--t), background var(--t);
|
||||
width: 100%;
|
||||
}
|
||||
.form__input::placeholder { color: var(--text-dim); opacity: 0.5; }
|
||||
.form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--cyan);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DASHBOARD
|
||||
============================================================ */
|
||||
.dashboard { padding: 3rem 0 4rem; }
|
||||
|
||||
.dash-header {
|
||||
margin-bottom: 2.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dash-header__title {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.dash-header__name { color: var(--cyan); }
|
||||
|
||||
.dash-header__email {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dash-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.dash-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-dim);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-actions { margin-top: 1rem; }
|
||||
|
||||
.dash-empty {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--font-size-md);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dash-empty-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TERMINAL WINDOW CHROME
|
||||
============================================================ */
|
||||
.term-window {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.term-window__chrome {
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.5rem 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.term-window__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.term-window__dot--red { background: #e05050; }
|
||||
.term-window__dot--yellow { background: #f0a020; }
|
||||
.term-window__dot--green { background: #00c8a0; }
|
||||
|
||||
.term-window__title {
|
||||
margin-left: 0.5rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.term-window__body { padding: 1.25rem 1.5rem; }
|
||||
|
||||
/* ============================================================
|
||||
STATUS ROWS (inside term-window)
|
||||
============================================================ */
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.status-row__label {
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.status-row__dots {
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted var(--border-bright);
|
||||
margin-bottom: 3px;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.status-row__value {
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STATUS BADGE
|
||||
============================================================ */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.55em;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge--active { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(0,200,240,0.25); }
|
||||
.status-badge--cancelled { background: var(--err-bg); color: var(--err); border: 1px solid rgba(224,80,80,0.25); }
|
||||
.status-badge--past_due { background: var(--warn-bg); color: var(--warn); border: 1px solid rgba(240,160,32,0.25); }
|
||||
.status-badge--paid { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(0,200,240,0.25); }
|
||||
.status-badge--open { background: var(--warn-bg); color: var(--warn); border: 1px solid rgba(240,160,32,0.25); }
|
||||
.status-badge--void { background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--border); }
|
||||
|
||||
/* ============================================================
|
||||
INVOICE TABLE
|
||||
============================================================ */
|
||||
.inv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.inv-table__th {
|
||||
text-align: left;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inv-table__th--right { text-align: right; }
|
||||
|
||||
.inv-table__row:hover { background: var(--surface-2); }
|
||||
|
||||
.inv-table__td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.inv-table__td--muted { color: var(--text-dim); }
|
||||
.inv-table__td--right { text-align: right; }
|
||||
|
||||
/* ============================================================
|
||||
LINKS
|
||||
============================================================ */
|
||||
.link { color: var(--cyan); text-decoration: none; transition: color var(--t); }
|
||||
.link:hover { color: #fff; }
|
||||
.link--sm { font-size: var(--font-size-sm); }
|
||||
Reference in New Issue
Block a user