refactor: python to go
This commit is contained in:
53
internal/config/config.go
Normal file
53
internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPass string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
Host: getEnv("HOST", "0.0.0.0"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DBHost: getEnv("PG_HOST", "localhost"),
|
||||
DBPort: getEnv("PG_PORT", "5432"),
|
||||
DBName: getEnv("PG_DATABASE", "newsletter"),
|
||||
DBUser: getEnv("PG_USER", "postgres"),
|
||||
DBPass: getEnv("PG_PASSWORD", ""),
|
||||
SMTPHost: getEnv("SMTP_SERVER", ""),
|
||||
SMTPPort: getEnv("SMTP_PORT", "587"),
|
||||
SMTPUser: getEnv("SMTP_USER", ""),
|
||||
SMTPPass: getEnv("SMTP_PASSWORD", ""),
|
||||
}
|
||||
|
||||
if cfg.SMTPHost == "" {
|
||||
return nil, fmt.Errorf("SMTP_SERVER not configured")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
160
internal/database/database.go
Normal file
160
internal/database/database.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"landing/internal/config"
|
||||
"landing/internal/models"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*DB, error) {
|
||||
// Use proper pgx connection config instead of URL parsing
|
||||
connConfig, err := pgxpool.ParseConfig("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
connConfig.ConnConfig.Host = cfg.DBHost
|
||||
connConfig.ConnConfig.Port = 5432
|
||||
connConfig.ConnConfig.Database = cfg.DBName
|
||||
connConfig.ConnConfig.User = cfg.DBUser
|
||||
connConfig.ConnConfig.Password = cfg.DBPass
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
10*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, connConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) InitDB(ctx context.Context) error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS subscribers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS newsletters (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.pool.Exec(ctx, query); err != nil {
|
||||
return fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) AddSubscriber(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
_, err := db.pool.Exec(
|
||||
ctx,
|
||||
"INSERT INTO subscribers (email) VALUES ($1)",
|
||||
email,
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() == "ERROR: duplicate key value" {
|
||||
return fmt.Errorf("email already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RemoveSubscriber(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
result, err := db.pool.Exec(
|
||||
ctx,
|
||||
"DELETE FROM subscribers WHERE email = $1",
|
||||
email,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("email not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetNewsletters(
|
||||
ctx context.Context,
|
||||
) ([]models.Newsletter, error) {
|
||||
rows, err := db.pool.Query(
|
||||
ctx,
|
||||
"SELECT id, subject, body, sent_at FROM newsletters "+
|
||||
"ORDER BY sent_at DESC",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var newsletters []models.Newsletter
|
||||
for rows.Next() {
|
||||
var n models.Newsletter
|
||||
err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newsletters = append(newsletters, n)
|
||||
}
|
||||
|
||||
return newsletters, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetNewsletter(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
) (*models.Newsletter, error) {
|
||||
var n models.Newsletter
|
||||
err := db.pool.QueryRow(
|
||||
ctx,
|
||||
"SELECT id, subject, body, sent_at FROM newsletters "+
|
||||
"WHERE id = $1",
|
||||
id,
|
||||
).Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("newsletter not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close(ctx context.Context) {
|
||||
db.pool.Close()
|
||||
}
|
||||
60
internal/email/sender.go
Normal file
60
internal/email/sender.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
"landing/internal/config"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Sender {
|
||||
return &Sender{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Sender) SendConfirmationEmail(
|
||||
email string,
|
||||
unsubscribeLink string,
|
||||
) error {
|
||||
client, err := mail.NewClient(
|
||||
s.cfg.SMTPHost,
|
||||
mail.WithPort(587),
|
||||
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
||||
mail.WithUsername(s.cfg.SMTPUser),
|
||||
mail.WithPassword(s.cfg.SMTPPass),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mail client: %w", err)
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.cfg.SMTPUser); err != nil {
|
||||
return fmt.Errorf("failed to set from: %w", err)
|
||||
}
|
||||
if err := msg.To(email); err != nil {
|
||||
return fmt.Errorf("failed to set to: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject("Thanks for subscribing!")
|
||||
|
||||
htmlBody := fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Welcome!</h1>
|
||||
<p>Thank you for subscribing to our newsletter.</p>
|
||||
<p><a href="%s">Unsubscribe</a></p>
|
||||
</body>
|
||||
</html>
|
||||
`, unsubscribeLink)
|
||||
|
||||
msg.SetBodyString(mail.TypeTextHTML, htmlBody)
|
||||
|
||||
if err := client.DialAndSend(msg); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
310
internal/handlers/handlers.go
Normal file
310
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"landing/internal/config"
|
||||
"landing/internal/database"
|
||||
"landing/internal/email"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *database.DB
|
||||
cfg *config.Config
|
||||
email *email.Sender
|
||||
templatesPath string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New(db *database.DB, cfg *config.Config) *Handler {
|
||||
templatesPath := "templates"
|
||||
if _, err := os.Stat(templatesPath); os.IsNotExist(err) {
|
||||
templatesPath = "./templates"
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
email: email.New(cfg),
|
||||
templatesPath: templatesPath,
|
||||
logger: log.New(os.Stdout, "", log.LstdFlags),
|
||||
}
|
||||
}
|
||||
|
||||
// loggingMiddleware logs HTTP requests
|
||||
func (h *Handler) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Create a custom response writer to capture status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
// Call the next handler
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
// Log the request
|
||||
duration := time.Since(start)
|
||||
statusColor := getStatusColor(wrapped.statusCode)
|
||||
methodColor := getMethodColor(r.Method)
|
||||
|
||||
h.logger.Printf(
|
||||
"%s %s %s %s %s %d %s",
|
||||
methodColor+r.Method+"\033[0m",
|
||||
r.RequestURI,
|
||||
statusColor+fmt.Sprintf("%d", wrapped.statusCode)+"\033[0m",
|
||||
duration.String(),
|
||||
r.RemoteAddr,
|
||||
wrapped.contentLength,
|
||||
r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
contentLength int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
rw.contentLength = len(b)
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Color codes for terminal output
|
||||
func getStatusColor(statusCode int) string {
|
||||
switch {
|
||||
case statusCode >= 200 && statusCode < 300:
|
||||
return "\033[32m" // Green
|
||||
case statusCode >= 300 && statusCode < 400:
|
||||
return "\033[36m" // Cyan
|
||||
case statusCode >= 400 && statusCode < 500:
|
||||
return "\033[33m" // Yellow
|
||||
case statusCode >= 500:
|
||||
return "\033[31m" // Red
|
||||
default:
|
||||
return "\033[37m" // White
|
||||
}
|
||||
}
|
||||
|
||||
func getMethodColor(method string) string {
|
||||
switch method {
|
||||
case "GET":
|
||||
return "\033[34m" // Blue
|
||||
case "POST":
|
||||
return "\033[32m" // Green
|
||||
case "PUT":
|
||||
return "\033[33m" // Yellow
|
||||
case "DELETE":
|
||||
return "\033[31m" // Red
|
||||
default:
|
||||
return "\033[37m" // White
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Start(host, port string) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Serve static files
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
|
||||
|
||||
mux.HandleFunc("/", h.indexHandler)
|
||||
mux.HandleFunc("/subscribe", h.subscribeHandler)
|
||||
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
|
||||
mux.HandleFunc("/newsletters", h.newslettersHandler)
|
||||
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
|
||||
|
||||
// Wrap with logging middleware
|
||||
handler := h.loggingMiddleware(mux)
|
||||
|
||||
h.logger.Printf("\033[36m▶ Starting server on http://%s:%s\033[0m", host, port)
|
||||
|
||||
return http.ListenAndServe(host+":"+port, handler)
|
||||
}
|
||||
|
||||
func (h *Handler) getTemplatePath(name string) string {
|
||||
return filepath.Join(h.templatesPath, name)
|
||||
}
|
||||
|
||||
func (h *Handler) indexHandler(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles(
|
||||
h.getTemplatePath("base.html"),
|
||||
h.getTemplatePath("index.html"),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"IsHome": true,
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "base.html", data)
|
||||
}
|
||||
|
||||
func (h *Handler) subscribeHandler(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Email is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.AddSubscriber(r.Context(), req.Email); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Email already exists",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
unsubscribeLink := fmt.Sprintf(
|
||||
"%s/unsubscribe?email=%s",
|
||||
getBaseURL(r),
|
||||
req.Email,
|
||||
)
|
||||
|
||||
if err := h.email.SendConfirmationEmail(
|
||||
req.Email,
|
||||
unsubscribeLink,
|
||||
); err != nil {
|
||||
h.logger.Printf("❌ Failed to send confirmation email to %s: %v", req.Email, err)
|
||||
} else {
|
||||
h.logger.Printf("✓ Confirmation email sent to %s", req.Email)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Email has been added",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) unsubscribeHandler(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
http.Error(w, "No email specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.RemoveSubscriber(r.Context(), email); err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf(
|
||||
"Email %s was not found or already unsubscribed",
|
||||
email,
|
||||
),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("✓ Unsubscribed %s", email)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintf(w, "The email %s has been unsubscribed.", email)
|
||||
}
|
||||
|
||||
func (h *Handler) newslettersHandler(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
newsletters, err := h.db.GetNewsletters(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch newsletters", 500)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles(
|
||||
h.getTemplatePath("base.html"),
|
||||
h.getTemplatePath("newsletters.html"),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "base.html", newsletters)
|
||||
}
|
||||
|
||||
func (h *Handler) newsletterDetailHandler(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
idStr := r.URL.Path[len("/newsletter/"):]
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid newsletter ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
newsletter, err := h.db.GetNewsletter(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Newsletter not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles(
|
||||
h.getTemplatePath("base.html"),
|
||||
h.getTemplatePath("newsletter_detail.html"),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.ExecuteTemplate(w, "base.html", newsletter)
|
||||
}
|
||||
|
||||
func getBaseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
}
|
||||
15
internal/models/models.go
Normal file
15
internal/models/models.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Subscriber struct {
|
||||
ID int
|
||||
Email string
|
||||
}
|
||||
|
||||
type Newsletter struct {
|
||||
ID int
|
||||
Subject string
|
||||
Body string
|
||||
SentAt time.Time
|
||||
}
|
||||
Reference in New Issue
Block a user