feat: complete go rewrite
This commit is contained in:
81
internal/config/config.go
Normal file
81
internal/config/config.go
Normal 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
|
||||
}
|
||||
160
internal/database/database.go
Normal file
160
internal/database/database.go
Normal 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
81
internal/email/email.go
Normal 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
49
internal/handlers/auth.go
Normal 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")
|
||||
}
|
||||
28
internal/handlers/newsletter.go
Normal file
28
internal/handlers/newsletter.go
Normal 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})
|
||||
}
|
||||
19
internal/handlers/subscribers.go
Normal file
19
internal/handlers/subscribers.go
Normal 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})
|
||||
}
|
||||
42
internal/middleware/auth.go
Normal file
42
internal/middleware/auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user