feat: complete go rewrite

This commit is contained in:
Blake Ridgway
2025-11-12 19:18:29 -06:00
parent 9d78f1fdb4
commit cb3293c7b0
26 changed files with 1286 additions and 531 deletions

81
internal/config/config.go Normal file
View File

@@ -0,0 +1,81 @@
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
Port string
PGHost string
PGPort string
PGUser string
PGPassword string
PGDatabase string
SMTPServer string
SMTPPort int
SMTPUser string
SMTPPassword string
SenderEmail string
AdminUsername string
AdminPassword string
SecretKey string
BaseURL string
}
var Current *Config
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
cfg := &Config{
Port: getEnv("PORT", "5001"),
PGHost: getEnv("PG_HOST", "localhost"),
PGPort: getEnv("PG_PORT", "5432"),
PGUser: getEnv("PG_USER", "postgres"),
PGPassword: getEnv("PG_PASSWORD", ""),
PGDatabase: getEnv("PG_DATABASE", "newsletter"),
SMTPServer: getEnv("SMTP_SERVER", ""),
SMTPPort: getEnvInt("SMTP_PORT", 465),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SenderEmail: getEnv("SENDER_EMAIL", ""),
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", "changeme"),
SecretKey: getEnv("SECRET_KEY", "your-secret-key"),
BaseURL: getEnv("BASE_URL", "localhost:5001"),
}
if cfg.SenderEmail == "" {
cfg.SenderEmail = cfg.SMTPUser
}
Current = cfg
return cfg
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func getEnvInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
intVal, err := strconv.Atoi(value)
if err != nil {
log.Printf("Invalid integer for %s: %v", key, err)
return defaultValue
}
return intVal
}

View File

@@ -0,0 +1,160 @@
package database
import (
"database/sql"
"fmt"
"log"
"net/url"
"github.com/rideaware/admin-panel/internal/config"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
var db *sql.DB
type Admin struct {
Username string
Password string
}
func Init(cfg *config.Config) {
password := url.QueryEscape(cfg.PGPassword)
psqlInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=require",
cfg.PGUser, password, cfg.PGHost, cfg.PGPort, cfg.PGDatabase,
)
log.Printf("Connecting to database: postgres://%s:***@%s:%s/%s",
cfg.PGUser, cfg.PGHost, cfg.PGPort, cfg.PGDatabase)
var err error
db, err = sql.Open("postgres", psqlInfo)
if err != nil {
log.Fatalf("Database connection error: %v", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
if err = db.Ping(); err != nil {
log.Fatalf("Failed to ping database: %v", err)
}
log.Println("Database connection successful!")
createTables()
createDefaultAdmin(cfg)
}
func Close() {
if db != nil {
db.Close()
}
}
func createTables() {
queries := []string{
`CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS newsletters (
id SERIAL PRIMARY KEY,
subject TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, query := range queries {
if _, err := db.Exec(query); err != nil {
log.Printf("Error creating table: %v", err)
}
}
log.Println("Database tables ready.")
}
func GetAllEmails() ([]string, error) {
rows, err := db.Query("SELECT email FROM subscribers")
if err != nil {
log.Printf("Error retrieving emails: %v", err)
return nil, err
}
defer rows.Close()
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, rows.Err()
}
func GetAdmin(username string) (*Admin, error) {
var admin Admin
err := db.QueryRow(
"SELECT username, password FROM admin_users WHERE username=$1",
username,
).Scan(&admin.Username, &admin.Password)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("admin not found")
}
return nil, err
}
return &admin, nil
}
func createDefaultAdmin(cfg *config.Config) {
hashedPassword, err := hashPassword(cfg.AdminPassword)
if err != nil {
log.Fatalf("Error hashing password: %v", err)
}
_, err = db.Exec(
"INSERT INTO admin_users (username, password) VALUES ($1, $2) "+
"ON CONFLICT (username) DO NOTHING",
cfg.AdminUsername, hashedPassword,
)
if err != nil {
log.Printf("Error creating default admin: %v", err)
} else {
log.Println("Default admin user ready.")
}
}
func LogNewsletter(subject, body string) error {
_, err := db.Exec(
"INSERT INTO newsletters (subject, body) VALUES ($1, $2)",
subject, body,
)
return err
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
return string(hash), err
}
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword(
[]byte(hash),
[]byte(password),
) == nil
}

81
internal/email/email.go Normal file
View File

@@ -0,0 +1,81 @@
package email
import (
"fmt"
"log"
"time"
"github.com/rideaware/admin-panel/internal/config"
"github.com/rideaware/admin-panel/internal/database"
"github.com/wneessen/go-mail"
)
func SendUpdate(subject, body string) (string, error) {
subscribers, err := database.GetAllEmails()
if err != nil {
return "Failed to retrieve subscribers", err
}
if len(subscribers) == 0 {
return "No subscribers found.", nil
}
for _, email := range subscribers {
if !send(subject, body, email) {
return fmt.Sprintf("Failed to send to %s", email), nil
}
}
if err := database.LogNewsletter(subject, body); err != nil {
log.Printf("Error logging newsletter: %v", err)
}
return "Email has been sent to all subscribers.", nil
}
func send(subject, body, recipient string) bool {
cfg := config.Current
client, err := mail.NewClient(
cfg.SMTPServer,
mail.WithPort(cfg.SMTPPort),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(cfg.SMTPUser),
mail.WithPassword(cfg.SMTPPassword),
mail.WithTimeout(10*time.Second),
)
if err != nil {
log.Printf("Failed to create mail client: %v", err)
return false
}
defer client.Close()
m := mail.NewMsg()
if err := m.From(cfg.SenderEmail); err != nil {
log.Printf("Failed to set from: %v", err)
return false
}
if err := m.To(recipient); err != nil {
log.Printf("Failed to set to: %v", err)
return false
}
m.Subject(subject)
unsubLink := fmt.Sprintf("https://%s/unsubscribe?email=%s",
cfg.BaseURL, recipient)
htmlBody := fmt.Sprintf(
"%s<br><br>If you ever wish to unsubscribe, "+
"please click <a href='%s'>here</a>",
body, unsubLink)
m.SetBodyString(mail.TypeTextHTML, htmlBody)
if err := client.Send(m); err != nil {
log.Printf("Failed to send email to %s: %v", recipient, err)
return false
}
log.Printf("Update email sent to: %s", recipient)
return true
}

49
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"github.com/rideaware/admin-panel/internal/database"
"github.com/rideaware/admin-panel/internal/middleware"
"github.com/gin-gonic/gin"
)
func LoginGet(c *gin.Context) {
c.HTML(http.StatusOK, "login.html", gin.H{})
}
func LoginPost(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
admin, err := database.GetAdmin(username)
if err != nil || !database.VerifyPassword(admin.Password, password) {
c.HTML(http.StatusUnauthorized, "login.html",
gin.H{"error": "Invalid username or password"})
return
}
session, err := middleware.GetStore().Get(c.Request, "session")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
session.Values["username"] = username
if err := session.Save(c.Request, c.Writer); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusFound, "/")
}
func Logout(c *gin.Context) {
session, err := middleware.GetStore().Get(c.Request, "session")
if err == nil {
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
}
c.Redirect(http.StatusFound, "/login")
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"net/http"
"github.com/rideaware/admin-panel/internal/email"
"github.com/gin-gonic/gin"
)
func SendUpdateGet(c *gin.Context) {
c.HTML(http.StatusOK, "send_update.html", gin.H{})
}
func SendUpdatePost(c *gin.Context) {
subject := c.PostForm("subject")
body := c.PostForm("body")
message, err := email.SendUpdate(subject, body)
if err != nil {
c.HTML(http.StatusOK, "send_update.html",
gin.H{"error": message})
return
}
c.HTML(http.StatusOK, "send_update.html",
gin.H{"success": message})
}

View File

@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"github.com/rideaware/admin-panel/internal/database"
"github.com/gin-gonic/gin"
)
func IndexGet(c *gin.Context) {
emails, err := database.GetAllEmails()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.HTML(http.StatusOK, "admin_index.html",
gin.H{"emails": emails})
}

View File

@@ -0,0 +1,42 @@
package middleware
import (
"net/http"
"github.com/rideaware/admin-panel/internal/config"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
)
var store *sessions.CookieStore
func Init() {
if config.Current.SecretKey == "" {
panic("SECRET_KEY not set")
}
store = sessions.NewCookieStore([]byte(config.Current.SecretKey))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
Secure: false,
SameSite: 0,
}
}
func GetStore() *sessions.CookieStore {
return store
}
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
session, err := store.Get(c.Request, "session")
if err != nil || session.Values["username"] == nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
c.Next()
}
}