Compare commits

...

10 Commits

Author SHA1 Message Date
Blake Ridgway
5e737ebdd3 ci-ish: build script adapted for the admin panel 2025-11-21 08:18:40 -06:00
Blake Ridgway
59514f721a Merge pull request #3 from RideAware/feat/go-rewrite
Fix podman building, syntax, updated packages
2025-11-17 08:54:59 -06:00
Cipher Vance
f6029f4a66 fix: fixed syntax error 2025-11-17 08:37:42 -06:00
Cipher Vance
66300be495 chore: updated package versions 2025-11-17 08:37:27 -06:00
Cipher Vance
a92e836cc1 ci: golang to 1.25 2025-11-17 08:37:02 -06:00
Blake Ridgway
97f25814a1 Merge pull request #1 from RideAware/feat/go-rewrite
Feat: Rewrite from Python to Go
2025-11-15 20:02:40 -06:00
Cipher Vance
1b91b72ffb set tls version to 1.2 2025-11-15 19:11:39 -06:00
Cipher Vance
85e49c9e9f some more email work for smtp errors 2025-11-15 18:48:14 -06:00
Cipher Vance
9444bab05f add support for better ssl/tls handling 2025-11-15 18:43:51 -06:00
Cipher Vance
ae3a484139 add gin_mode env 2025-11-15 15:41:28 -06:00
6 changed files with 282 additions and 66 deletions

View File

@@ -1,5 +1,5 @@
# Stage 1: Build
FROM docker.io/library/golang:1.23-alpine AS builder
FROM docker.io/library/golang:1.25-alpine AS builder
WORKDIR /build

View File

@@ -2,6 +2,7 @@ package main
import (
"log"
"os"
"github.com/rideaware/admin-panel/internal/config"
"github.com/rideaware/admin-panel/internal/database"
@@ -17,11 +18,22 @@ import (
// authenticated routes, and starts the HTTP server on the configured port.
func main() {
cfg := config.Load()
// Set Gin mode based on environment (default to release)
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
middleware.Init()
database.Init(cfg)
defer database.Close()
router := gin.Default()
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
// Trust only localhost proxy in production
router.SetTrustedProxies([]string{"127.0.0.1", "localhost", "::1"})
router.LoadHTMLGlob("web/templates/*.html")
router.Static("/static", "web/static")

13
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/rideaware/admin-panel
go 1.23
go 1.24.0
toolchain go1.24.10
@@ -9,8 +9,7 @@ require (
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/wneessen/go-mail v0.4.0
golang.org/x/crypto v0.14.0
golang.org/x/crypto v0.36.0
)
require (
@@ -33,9 +32,9 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

24
go.sum
View File

@@ -23,7 +23,6 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -70,26 +69,23 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wneessen/go-mail v0.4.0 h1:Oo4HLIV8My7G9JuZkoOX6eipXQD+ACvIqURYeIzUc88=
github.com/wneessen/go-mail v0.4.0/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,25 +1,19 @@
package email
import (
"crypto/tls"
"fmt"
"log"
"net/smtp"
"net/url"
"strings"
"time"
"github.com/rideaware/admin-panel/internal/config"
"github.com/rideaware/admin-panel/internal/database"
"github.com/wneessen/go-mail"
)
// SendUpdate sends a newsletter with the given subject and body to all subscriber emails stored in the database.
// It returns a human-readable status message and, when subscriber retrieval fails, the underlying error.
// - If retrieving subscribers fails: returns "Failed to retrieve subscribers" and the error.
// - If no subscribers are found: returns "No subscribers found." and nil.
// - If sending to a specific subscriber fails: returns "Failed to send to <email>" and nil.
// - On success: returns "Email has been sent to all subscribers." and nil.
// Note: logging the newsletter entry in the database is attempted after sending and any logging failure is non-fatal.
func SendUpdate(subject, body string) (string, error) {
subscribers, err := database.GetAllEmails()
if err != nil {
@@ -28,6 +22,7 @@ func SendUpdate(subject, body string) (string, error) {
if len(subscribers) == 0 {
return "No subscribers found.", nil
}
var succeeded, failed int
for _, email := range subscribers {
if send(subject, body, email) {
@@ -36,79 +31,119 @@ func SendUpdate(subject, body string) (string, error) {
failed++
}
}
if err := database.LogNewsletter(subject, body); err != nil {
log.Printf("Error logging newsletter: %v", err)
}
if failed == 0 {
return fmt.Sprintf("Email sent to all %d subscribers.", succeeded), nil
}
return fmt.Sprintf("Sent to %d/%d subscribers; %d failed.", succeeded, succeeded+failed, failed), nil
}
// send constructs and sends an HTML newsletter update to the specified recipient using the current SMTP configuration.
// It embeds an unsubscribe link for the recipient and returns true if the message was sent successfully, false if client creation, message setup, or sending fails.
// send constructs and sends an HTML newsletter update to the specified recipient
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()
log.Printf("Attempting to send email to %s via %s:%d", recipient, cfg.SMTPServer, cfg.SMTPPort)
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)
addr := fmt.Sprintf("%s:%d", cfg.SMTPServer, cfg.SMTPPort)
unsubLink := fmt.Sprintf("https://%s/unsubscribe?email=%s",
cfg.BaseURL, url.QueryEscape(recipient))
// Build HTML body with unsubscribe link
htmlBody := buildHTMLBody(body, unsubLink)
m.SetBodyString(mail.TypeTextHTML, htmlBody)
message := buildMessage(cfg.SenderEmail, recipient, subject, htmlBody)
if err := client.Send(m); err != nil {
log.Printf("Failed to send email to %s: %v", recipient, err)
// Create TLS connection
tlsconfig := &tls.Config{
ServerName: cfg.SMTPServer,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsconfig)
if err != nil {
log.Printf("Failed to connect to SMTP %s: %v", addr, err)
return false
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, cfg.SMTPServer)
if err != nil {
log.Printf("Failed to create SMTP client: %v", err)
return false
}
defer client.Close()
// Authenticate
auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPServer)
if err := client.Auth(auth); err != nil {
log.Printf("SMTP auth failed for %s: %v", cfg.SMTPUser, err)
return false
}
// Send the email
if err := client.Mail(cfg.SenderEmail); err != nil {
log.Printf("MAIL command failed: %v", err)
return false
}
if err := client.Rcpt(recipient); err != nil {
log.Printf("RCPT command failed for %s: %v", recipient, err)
return false
}
w, err := client.Data()
if err != nil {
log.Printf("DATA command failed: %v", err)
return false
}
_, err = w.Write([]byte(message))
if err != nil {
log.Printf("Failed to write message: %v", err)
return false
}
err = w.Close()
if err != nil {
log.Printf("Failed to close DATA: %v", err)
return false
}
client.Quit()
log.Printf("Update email sent to: %s", recipient)
return true
}
func buildMessage(from, to, subject, body string) string {
msg := fmt.Sprintf("From: %s\r\n", from)
msg += fmt.Sprintf("To: %s\r\n", to)
msg += fmt.Sprintf("Subject: %s\r\n", subject)
msg += "MIME-Version: 1.0\r\n"
msg += "Content-Type: text/html; charset=\"utf-8\"\r\n"
msg += "\r\n"
msg += body
return msg
}
// buildHTMLBody constructs the final HTML email body by appending an unsubscribe footer to the user-provided content.
// It handles both complete HTML documents and HTML fragments.
func buildHTMLBody(body, unsubLink string) string {
footer := fmt.Sprintf(
"<br><br><hr><p style='font-size: 12px; color: #666;'>If you ever wish to unsubscribe, "+
"please click <a href='%s'>here</a>.</p>",
unsubLink)
// If body contains closing html tag, insert before it
if strings.Contains(strings.ToLower(body), "</html>") {
return strings.Replace(body, "</html>", footer+"</html>", 1)
}
// If body contains closing body tag, insert before it
if strings.Contains(strings.ToLower(body), "</body>") {
return strings.Replace(body, "</body>", footer+"</body>", 1)
}
// Otherwise just append
return body + footer
}

174
scripts/build.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
IMAGE_NAME="rideaware-admin"
IMAGE_TAG="latest"
NO_CACHE=false
RUN_CONTAINER=false
CONTAINER_NAME="rideaware-admin"
# Help function
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-t, --tag TAG Image tag (default: latest)
-n, --name NAME Image name (default: rideaware)
-r, --run Run container after build
-c, --container NAME Container name when running (default: rideaware-admin)
--no-cache Build without cache
-h, --help Show this help message
EXAMPLES:
$0 # Build as rideaware:latest
$0 -t v1.0 # Build as rideaware:v1.0
$0 -t dev --run # Build and run
$0 --no-cache -t prod # Build without cache as rideaware:prod
EOF
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
IMAGE_TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
-r|--run)
RUN_CONTAINER=true
shift
;;
-c|--container)
CONTAINER_NAME="$2"
shift 2
;;
--no-cache)
NO_CACHE=true
shift
;;
-h|--help)
show_help
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
show_help
;;
esac
done
FULL_IMAGE="$IMAGE_NAME:$IMAGE_TAG"
BUILD_ARGS=""
if [ "$NO_CACHE" = true ]; then
BUILD_ARGS="--no-cache"
fi
# Function to stop and remove container
cleanup_container() {
local name=$1
if podman ps -a --format "{{.Names}}" | grep -q "^${name}\$"; then
echo -e "${YELLOW}Removing existing container: $name${NC}"
# Stop if running
if podman ps --format "{{.Names}}" | grep -q "^${name}\$"; then
echo " Stopping container..."
podman kill "$name" 2>/dev/null || true
fi
# Remove
echo " Removing container..."
if podman rm "$name" 2>/dev/null; then
echo -e "${GREEN} ✓ Container removed${NC}"
else
echo -e "${RED} ✗ Failed to remove container${NC}"
return 1
fi
fi
return 0
}
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Building Podman Image ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo -e "${YELLOW}Image: $FULL_IMAGE${NC}"
echo ""
if ! podman build $BUILD_ARGS -f Dockerfile -t "$FULL_IMAGE" .; then
echo -e "${RED}✗ Build failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Image built successfully${NC}"
echo ""
# Show image info
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Image Details ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
podman images "$IMAGE_NAME:$IMAGE_TAG" \
--format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Created}}"
echo ""
if [ "$RUN_CONTAINER" = true ]; then
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Starting Container ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
# Cleanup existing container
if ! cleanup_container "$CONTAINER_NAME"; then
echo -e "${RED}✗ Failed to clean up existing container${NC}"
exit 1
fi
echo ""
echo "Starting new container: $CONTAINER_NAME"
if podman run -d \
--name "$CONTAINER_NAME" \
-p 5001:5001 \
--env-file .env \
"$FULL_IMAGE"; then
echo -e "${GREEN}✓ Container running: $CONTAINER_NAME${NC}"
echo ""
# Wait for startup
sleep 2
echo -e "${YELLOW}Container logs:${NC}"
podman logs "$CONTAINER_NAME"
echo ""
echo -e "${GREEN}API available at: http://localhost:5001${NC}"
echo -e "${YELLOW}To view logs: podman logs -f $CONTAINER_NAME${NC}"
echo -e "${YELLOW}To stop: podman kill $CONTAINER_NAME${NC}"
else
echo -e "${RED}✗ Failed to start container${NC}"
exit 1
fi
else
echo -e "${YELLOW}To run the container:${NC}"
echo " podman run -d --name $CONTAINER_NAME -p 5001:5001 --env-file .env $FULL_IMAGE"
echo ""
echo -e "${YELLOW}Or use this script with --run:${NC}"
echo " $0 -t $IMAGE_TAG --run"
fi
echo ""
echo -e "${GREEN}✓ Done!${NC}"