Compare commits

...
4 Commits
Author SHA1 Message Date
cody af9c23c521 start implementing auth 2026-02-23 14:34:19 -05:00
cody 715c122eb7 implement atlas for db migration 2026-02-22 15:06:16 -07:00
cody 09804829bd add postgres docker compose for local dev 2026-02-22 15:05:48 -07:00
cody f83a8b0697 update git ignore 2026-02-22 15:05:22 -07:00
16 changed files with 376 additions and 48 deletions
+1
View File
@@ -1,3 +1,4 @@
.env .env
.dockerenv .dockerenv
node_modules node_modules
tmp
+11
View File
@@ -0,0 +1,11 @@
env "local" {
src = "file://migrations/schema.sql"
url = "postgres://postgres:postgres@localhost:5433/go_weather?sslmode=disable"
dev = "docker://postgres/16/alpine/dev" # for schema validation
}
env "production" {
src = "file://migrations/schema.sql"
url = "postgres://${env("DB_USER")}:${env("DB_PASSWORD")}@${env("DB_HOST")}:${env("DB_PORT")}/${env("DB_NAME")}?sslmode=require"
dev = "docker://postgres/16/alpine/dev"
}
+2
View File
@@ -13,6 +13,7 @@ type Config struct {
DBUser string DBUser string
DBPassword string DBPassword string
DBName string DBName string
JWTSecret string
} }
func GetEnv(key string) string { func GetEnv(key string) string {
@@ -32,5 +33,6 @@ func Load() *Config {
DBUser: GetEnv("DB_USER"), DBUser: GetEnv("DB_USER"),
DBPassword: GetEnv("DB_PASSWORD"), DBPassword: GetEnv("DB_PASSWORD"),
DBName: GetEnv("DB_NAME"), DBName: GetEnv("DB_NAME"),
JWTSecret: GetEnv("JWT_SECRET"),
} }
} }
+4
View File
@@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import LatestTemperature from "@/components/latest-temperature" import LatestTemperature from "@/components/latest-temperature"
import TemperatureHistory from "@/components/temperature-history" import TemperatureHistory from "@/components/temperature-history"
import TemperaturePlot from "@/components/temperature-plot" import TemperaturePlot from "@/components/temperature-plot"
import RegisterForm from './components/register-form'
// import { Button } from "@/components/ui/button" // import { Button } from "@/components/ui/button"
@@ -12,6 +13,9 @@ function App() {
return ( return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<main className="min-h-screen bg-background p-6 w-full"> <main className="min-h-screen bg-background p-6 w-full">
<div>
<RegisterForm />
</div>
<div className="w-full mx-auto space-y-2"> <div className="w-full mx-auto space-y-2">
<TemperaturePlot /> <TemperaturePlot />
<div className="grid grid-cols-4 gap-6"> <div className="grid grid-cols-4 gap-6">
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
export default function RegisterForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError(null)
setSuccess(false)
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Passwords must be at least 8 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const msg = await res.text()
setError(msg || "Registration failed")
return
}
setSuccess(true)
setEmail("")
setPassword("")
setConfirmPassword("")
}
catch {
setError("Network error")
}
finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col border rounded-lg bg-card">
<h2>Register</h2>
{error && <p>{error}</p>}
{success && <p>Account created</p>}
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<Button type="submit" className="w-full">
{loading ? "Creating ..." : "Register"}
</Button>
</form>
)
}
-18
View File
@@ -72,24 +72,6 @@ h1 {
line-height: 1.1; line-height: 1.1;
} }
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
+5
View File
@@ -3,3 +3,8 @@ module go-weather
go 1.25.6 go 1.25.6
require github.com/lib/pq v1.11.1 require github.com/lib/pq v1.11.1
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
)
+4
View File
@@ -1,2 +1,6 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+62
View File
@@ -0,0 +1,62 @@
package handlers
import (
"encoding/json"
"go-weather/models"
"go-weather/services"
"net/http"
"strings"
)
type UserHandler struct {
userService *services.UserService
}
func NewUserHandler(us *services.UserService) *UserHandler {
return &UserHandler{userService: us}
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
http.Error(w, "email and password are required", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
return
}
exists, err := h.userService.EmailExists(req.Email)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "email already registered", http.StatusConflict)
return
}
user, err := h.userService.CreateUser(req.Email, req.Password)
if err != nil {
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
+6 -7
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
_ "github.com/lib/pq"
"go-weather/config" "go-weather/config"
"go-weather/handlers" "go-weather/handlers"
"go-weather/services" "go-weather/services"
@@ -12,6 +11,8 @@ import (
"os" "os"
"os/signal" "os/signal"
"time" "time"
_ "github.com/lib/pq"
) )
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) { func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
@@ -58,18 +59,15 @@ func main() {
log.Fatalf("failed to connect to db: %v", err) log.Fatalf("failed to connect to db: %v", err)
} }
defer database.Close() defer database.Close()
databaseService := services.NewDatabaseService(database)
// Setup weather_readings table
if err := databaseService.CreateTable(); err != nil {
log.Fatalf("failed to create table: %v", err)
}
// Create services // Create services
databaseService := services.NewDatabaseService(database)
weatherService := services.NewWeatherService(config.APIKey) weatherService := services.NewWeatherService(config.APIKey)
userService := services.NewUserService(database)
// Create route handlers // Create route handlers
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity) weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
userHandler := handlers.NewUserHandler(userService)
// Start background poller // Start background poller
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute) go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
@@ -80,6 +78,7 @@ func main() {
http.HandleFunc("/health", handlers.Health) http.HandleFunc("/health", handlers.Health)
http.HandleFunc("/latest-temperature", handlers.LatestTemperature) http.HandleFunc("/latest-temperature", handlers.LatestTemperature)
http.HandleFunc("/test", handlers.Test) http.HandleFunc("/test", handlers.Test)
http.HandleFunc("/register", userHandler.Register)
// Start server // Start server
go func() { go func() {
fmt.Println("server listening on :8080") fmt.Println("server listening on :8080")
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
+27
View File
@@ -0,0 +1,27 @@
package models
import (
"time"
)
type User struct {
ID int `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
User User `json:"user"`
Token string `json:"token"`
}
+23
View File
@@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
container_name: postgres-dev
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: go_weather
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
cloudbeaver:
image: dbeaver/cloudbeaver:latest
container_name: cloudbeaver-dev
ports:
- "8978:8978"
depends_on:
- postgres
volumes:
pgdata:
+57
View File
@@ -0,0 +1,57 @@
package services
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
type AuthService struct {
secretKey []byte
}
func NewAuthService(secret string) *AuthService {
return &AuthService{secretKey: []byte(secret)}
}
func (s *AuthService) GenerateToken(userID int, email string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
return token.SignedString(s.secretKey)
}
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secretKey, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
-21
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"go-weather/models" "go-weather/models"
"log"
"time" "time"
) )
@@ -32,26 +31,6 @@ func NewDatabaseService(db *sql.DB) *DatabaseService {
return &DatabaseService{db: db} return &DatabaseService{db: db}
} }
func (s *DatabaseService) CreateTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
)
`)
if err != nil {
log.Printf("failed to create table: %v", err)
}
fmt.Println("weather_readings table ready")
return nil
}
func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) { func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) {
conditions := "" conditions := ""
if len(weather.Weather) > 0 { if len(weather.Weather) > 0 {
+66
View File
@@ -0,0 +1,66 @@
package services
import (
"database/sql"
"go-weather/models"
"time"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
db *sql.DB
}
func NewUserService(db *sql.DB) *UserService {
return &UserService{db: db}
}
func (s *UserService) CreateUser(email string, password string) (*models.User, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
var user models.User
err = s.db.QueryRow(
`INSERT INTO users (email, password_hash, created_at)
VALUES ($1, $2, $3)
RETURNING id, email, created_at`,
email, string(hashedPassword), time.Now(),
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
var user models.User
err := s.db.QueryRow(
`SELECT id, email, password_hash, created_at
FROM users
WHERE email = $1`, email,
).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt)
if err != nil {
return nil, nil
}
return &user, nil
}
func (s *UserService) EmailExists(email string) (bool, error) {
var exists bool
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
return exists, err
}
func (s *UserService) ValidatePassword(user *models.User, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
return err == nil
}