HMC_Host_Dashboard/main.go

282 lines
7.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/russross/blackfriday/v2"
)
// ------------- Api configs -------------
type ApiConfig struct {
BatchSize int `toml:"batch_size"`
TestEmail string `toml:"test_email"`
HMC struct {
ApiUrl string `toml:"api_url"`
SharedSecret string `toml:"shared_secret"`
City string `toml:"city"`
} `toml:"hmc"`
Postmark struct {
ServerToken string `toml:"server_token"`
ApiUrl string `toml:"api_url"`
TemplateId int `toml:"template_id"`
SenderName string `toml:"sender_name"`
SenderEmail string `toml:"sender_email"`
MessageStream string `toml:"message_stream"`
} `toml:"postmark"`
}
// Load the HMCsend-email app.ini and store the values in a structure, some of the keys in here
// we probably don't actualy care about for this usage.
func loadConfig() ApiConfig {
configFile := "app.toml"
data, err := os.ReadFile(configFile)
if err != nil {
fmt.Println("Error reading TOML file:", err)
os.Exit(1)
}
config := ApiConfig{}
if err := toml.Unmarshal(data, &config); err != nil {
fmt.Println("Error unmarshaling TOML:", err)
os.Exit(1)
}
return config
}
func ParseArguments(rawArgs string) map[string]string {
pairs := strings.Split(rawArgs, "&")
result := make(map[string]string)
for _, pair := range pairs {
key_val := strings.Split(pair, "=")
if len(key_val) != 2 {
log.Fatalf("Cannot parse argument %v\n", pair)
}
result[key_val[0]] = key_val[1]
}
return result
}
/*
* This is taken directly from the https://git.handmadecities.com/meetups/meetupinvite2000 repo
* Although I have added the blackfriday processing to it rather than doing it separately.
*/
func parseMailFile(contents string) (subject string, body string, valid bool) {
/* Remove leading and trailing newlines */
/* *** */
contents = strings.TrimSpace(contents)
lines := strings.Split(contents, "\n")
/* Check if there is at least one line */
/* *** */
if len(lines) == 0 {
return "", "", false
}
/* Check if the first line is a title with the '#' Markdown symbol */
/* *** */
if strings.HasPrefix(lines[0], "#") { // Extract subject without the '#' symbol and leading/trailing spaces
subject = strings.TrimSpace(strings.TrimPrefix(lines[0], "#"))
} else {
// If the first line is not a title, return an error
return "", "", false
}
/* Concatenate the remaining lines to form the body */
/* *** */
body = strings.Join(lines[1:], "\n")
/* Check if the body is not empty */
/* *** */
if body == "" {
return "", "", false
}
html := blackfriday.Run([]byte(body))
/* If all checks pass, set valid to true */
/* *** */
return subject, string(html), true
}
func GetMailingList(cfg ApiConfig) []string {
type EmailsResonse struct {
Emails []string `json:"emails"`
}
body := []byte(fmt.Sprintf("{\"city\": \"%s\"}", cfg.HMC.City))
req, err := http.NewRequest("POST", cfg.HMC.ApiUrl, bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", cfg.HMC.SharedSecret)
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
response := &EmailsResonse{}
decode_error := json.NewDecoder(res.Body).Decode(&response)
if decode_error != nil {
panic(decode_error)
}
return response.Emails
}
func main() {
apiConfig := loadConfig()
postmarkTemplate := getPostmarkTemplate(apiConfig)
// Prepare the go html templates and static file server
templates := template.Must(template.ParseGlob("templates/*.html"))
static_files := http.FileServer(http.Dir("static/"))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", static_files))
// The base endpoint for the web-app simply renders the index tempalte
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
templates.ExecuteTemplate(w, "index", nil)
})
// Endpoint called when editing the text area and posts to the postmark api to generate the email preview
mux.HandleFunc("/mail-content", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer r.Body.Close()
arguments := ParseArguments(string(body))
decoded_markdown, err := url.QueryUnescape(arguments["email_input"])
if err != nil {
panic(err)
}
subject, html_body, valid := parseMailFile(decoded_markdown)
if !valid {
log.Print("Failed to parse the provided markdown")
return
}
// Return a response to HTMX
model := PostmarkTemplateModel{
Name: apiConfig.Postmark.SenderName,
Email: apiConfig.Postmark.SenderEmail,
Body: html_body,
Subject: subject, // TODO(jack): Get from user input on page
}
renderedEmail := renderPostmarkTemplate(apiConfig, postmarkTemplate, model)
// Render the priview to a file so that we can include it as an Iframe as the rendered HTML is a full document.
err = os.WriteFile("./static/preview.html", []byte(renderedEmail.Html), 0666)
if err != nil {
log.Panicf("Failed to write file %v", err)
}
templates.ExecuteTemplate(w, "mail_content", renderedEmail.Subject)
})
// Endpoint called on load or button press by htmx to retrieve and populate the mailing list
mux.HandleFunc("/mailing_list", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
mailing_list := GetMailingList(apiConfig)
templates.ExecuteTemplate(w, "mailing_list", mailing_list)
})
mux.HandleFunc("/send_email", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer r.Body.Close()
body_string := string(body)
arguments := ParseArguments(body_string)
fmt.Printf("%v\n", arguments)
var recipients []string
if arguments["destination"] == "test" {
recipients = []string{apiConfig.TestEmail}
} else if arguments["destination"] == "mailing_list" {
recipients = GetMailingList(apiConfig)
} else {
panic("unknown destination in arguments")
}
decoded_markdown, err := url.QueryUnescape(arguments["email_input"])
if err != nil {
panic(err)
}
subject, html_body, valid := parseMailFile(decoded_markdown)
if !valid {
log.Print("Failed to parse the provided markdown")
return
}
sendBatchWithTemplate(apiConfig, html_body, subject, recipients)
})
srv := &http.Server{
Addr: "0.0.0.0:80",
// Good practice to set timeouts to avoid Slowloris attacks.
WriteTimeout: time.Second * 15,
ReadTimeout: time.Second * 15,
IdleTimeout: time.Second * 60,
Handler: mux,
}
go func() {
if err := srv.ListenAndServe(); err != nil {
log.Println(err)
}
}()
////////////////////////////// Cleanup //////////////////////////////
c := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
signal.Notify(c, os.Interrupt)
log.Println("Waiting for interrupt")
// Block until we receive our signal.
<-c
// Create a deadline to wait for.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
// Defer calling the canclefunction returned from context.WithTimeout
defer cancel()
// Doesn't block if no connections, but will otherwise wait
// until the timeout deadline.
srv.Shutdown(ctx)
// Optionally, you could run srv.Shutdown in a goroutine and block on
// <-ctx.Done() if your application should wait for other services
// to finalize based on context cancellation.
log.Println("shutting down")
os.Exit(0)
}