123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652 |
- package main
- import (
- "encoding/json"
- "fmt"
- "html/template"
- "os"
- "path"
- "path/filepath"
- "strings"
- "syscall"
- "time"
- "github.com/jmoiron/sqlx"
- "github.com/jmoiron/sqlx/types"
- "github.com/knadh/goyesql/v2"
- goyesqlx "github.com/knadh/goyesql/v2/sqlx"
- "github.com/knadh/koanf"
- "github.com/knadh/koanf/maps"
- "github.com/knadh/koanf/parsers/toml"
- "github.com/knadh/koanf/providers/confmap"
- "github.com/knadh/koanf/providers/file"
- "github.com/knadh/koanf/providers/posflag"
- "github.com/knadh/listmonk/internal/bounce"
- "github.com/knadh/listmonk/internal/bounce/mailbox"
- "github.com/knadh/listmonk/internal/i18n"
- "github.com/knadh/listmonk/internal/manager"
- "github.com/knadh/listmonk/internal/media"
- "github.com/knadh/listmonk/internal/media/providers/filesystem"
- "github.com/knadh/listmonk/internal/media/providers/s3"
- "github.com/knadh/listmonk/internal/messenger"
- "github.com/knadh/listmonk/internal/messenger/email"
- "github.com/knadh/listmonk/internal/messenger/postback"
- "github.com/knadh/listmonk/internal/subimporter"
- "github.com/knadh/stuffbin"
- "github.com/labstack/echo"
- flag "github.com/spf13/pflag"
- )
- const (
- queryFilePath = "queries.sql"
- // Root URI of the admin frontend.
- adminRoot = "/admin"
- )
- // constants contains static, constant config values required by the app.
- type constants struct {
- RootURL string `koanf:"root_url"`
- LogoURL string `koanf:"logo_url"`
- FaviconURL string `koanf:"favicon_url"`
- FromEmail string `koanf:"from_email"`
- NotifyEmails []string `koanf:"notify_emails"`
- EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
- Lang string `koanf:"lang"`
- DBBatchSize int `koanf:"batch_size"`
- Privacy struct {
- IndividualTracking bool `koanf:"individual_tracking"`
- AllowBlocklist bool `koanf:"allow_blocklist"`
- AllowExport bool `koanf:"allow_export"`
- AllowWipe bool `koanf:"allow_wipe"`
- Exportable map[string]bool `koanf:"-"`
- } `koanf:"privacy"`
- AdminUsername []byte `koanf:"admin_username"`
- AdminPassword []byte `koanf:"admin_password"`
- UnsubURL string
- LinkTrackURL string
- ViewTrackURL string
- OptinURL string
- MessageURL string
- MediaProvider string
- BounceWebhooksEnabled bool
- BounceSESEnabled bool
- BounceSendgridEnabled bool
- }
- func initFlags() {
- f := flag.NewFlagSet("config", flag.ContinueOnError)
- f.Usage = func() {
- // Register --help handler.
- fmt.Println(f.FlagUsages())
- os.Exit(0)
- }
- // Register the commandline flags.
- f.StringSlice("config", []string{"config.toml"},
- "path to one or more config files (will be merged in order)")
- f.Bool("install", false, "setup database (first time)")
- f.Bool("idempotent", false, "make --install run only if the databse isn't already setup")
- f.Bool("upgrade", false, "upgrade database to the current version")
- f.Bool("version", false, "current version of the build")
- f.Bool("new-config", false, "generate sample config file")
- f.String("static-dir", "", "(optional) path to directory with static files")
- f.String("i18n-dir", "", "(optional) path to directory with i18n language files")
- f.Bool("yes", false, "assume 'yes' to prompts during --install/upgrade")
- if err := f.Parse(os.Args[1:]); err != nil {
- lo.Fatalf("error loading flags: %v", err)
- }
- if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
- lo.Fatalf("error loading config: %v", err)
- }
- }
- // initConfigFiles loads the given config files into the koanf instance.
- func initConfigFiles(files []string, ko *koanf.Koanf) {
- for _, f := range files {
- lo.Printf("reading config: %s", f)
- if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
- if os.IsNotExist(err) {
- lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
- }
- lo.Fatalf("error loadng config from file: %v.", err)
- }
- }
- }
- // initFileSystem initializes the stuffbin FileSystem to provide
- // access to bunded static assets to the app.
- func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem {
- var (
- // stuffbin real_path:virtual_alias paths to map local assets on disk
- // when there an embedded filestystem is not found.
- // These paths are joined with appDir.
- appFiles = []string{
- "./config.toml.sample:config.toml.sample",
- "./queries.sql:queries.sql",
- "./schema.sql:schema.sql",
- }
- frontendFiles = []string{
- // Admin frontend's static assets accessible at /admin/* during runtime.
- // These paths are sourced from frontendDir.
- "./:/admin",
- }
- staticFiles = []string{
- // These paths are joined with staticDir.
- "./email-templates:static/email-templates",
- "./public:/public",
- }
- i18nFiles = []string{
- // These paths are joined with i18nDir.
- "./:/i18n",
- }
- )
- // Get the executable's path.
- path, err := os.Executable()
- if err != nil {
- lo.Fatalf("error getting executable path: %v", err)
- }
- // Load embedded files in the executable.
- hasEmbed := true
- fs, err := stuffbin.UnStuff(path)
- if err != nil {
- hasEmbed = false
- // Running in local mode. Load local assets into
- // the in-memory stuffbin.FileSystem.
- lo.Printf("unable to initialize embedded filesystem (%v). Using local filesystem", err)
- fs, err = stuffbin.NewLocalFS("/")
- if err != nil {
- lo.Fatalf("failed to initialize local file for assets: %v", err)
- }
- }
- // If the embed failed, load app and frontend files from the compile-time paths.
- files := []string{}
- if !hasEmbed {
- files = append(files, joinFSPaths(appDir, appFiles)...)
- files = append(files, joinFSPaths(frontendDir, frontendFiles)...)
- }
- // Irrespective of the embeds, if there are user specified static or i18n paths,
- // load files from there and override default files (embedded or picked up from CWD).
- if !hasEmbed || i18nDir != "" {
- if i18nDir == "" {
- // Default dir in cwd.
- i18nDir = "i18n"
- }
- lo.Printf("will load i18n files from: %v", i18nDir)
- files = append(files, joinFSPaths(i18nDir, i18nFiles)...)
- }
- if !hasEmbed || staticDir != "" {
- if staticDir == "" {
- // Default dir in cwd.
- staticDir = "static"
- }
- lo.Printf("will load static files from: %v", staticDir)
- files = append(files, joinFSPaths(staticDir, staticFiles)...)
- }
- // No additional files to load.
- if len(files) == 0 {
- return fs
- }
- // Load files from disk and overlay into the FS.
- fStatic, err := stuffbin.NewLocalFS("/", files...)
- if err != nil {
- lo.Fatalf("failed reading static files from disk: '%s': %v", staticDir, err)
- }
- if err := fs.Merge(fStatic); err != nil {
- lo.Fatalf("error merging static files: '%s': %v", staticDir, err)
- }
- return fs
- }
- // initDB initializes the main DB connection pool and parse and loads the app's
- // SQL queries into a prepared query map.
- func initDB() *sqlx.DB {
- var dbCfg dbConf
- if err := ko.Unmarshal("db", &dbCfg); err != nil {
- lo.Fatalf("error loading db config: %v", err)
- }
- lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
- db, err := connectDB(dbCfg)
- if err != nil {
- lo.Fatalf("error connecting to DB: %v", err)
- }
- return db
- }
- // initQueries loads named SQL queries from the queries file and optionally
- // prepares them.
- func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQueries bool) (goyesql.Queries, *Queries) {
- // Load SQL queries.
- qB, err := fs.Read(sqlFile)
- if err != nil {
- lo.Fatalf("error reading SQL file %s: %v", sqlFile, err)
- }
- qMap, err := goyesql.ParseBytes(qB)
- if err != nil {
- lo.Fatalf("error parsing SQL queries: %v", err)
- }
- if !prepareQueries {
- return qMap, nil
- }
- // Prepare queries.
- var q Queries
- if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
- lo.Fatalf("error preparing SQL queries: %v", err)
- }
- return qMap, &q
- }
- // initSettings loads settings from the DB.
- func initSettings(q *sqlx.Stmt) {
- var s types.JSONText
- if err := q.Get(&s); err != nil {
- lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err))
- }
- // Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
- // nested maps {app: {favicon_url}}.
- var out map[string]interface{}
- if err := json.Unmarshal(s, &out); err != nil {
- lo.Fatalf("error unmarshalling settings from DB: %v", err)
- }
- if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
- lo.Fatalf("error parsing settings from DB: %v", err)
- }
- }
- func initConstants() *constants {
- // Read constants.
- var c constants
- if err := ko.Unmarshal("app", &c); err != nil {
- lo.Fatalf("error loading app config: %v", err)
- }
- if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
- lo.Fatalf("error loading app config: %v", err)
- }
- c.RootURL = strings.TrimRight(c.RootURL, "/")
- c.Lang = ko.String("app.lang")
- c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
- c.MediaProvider = ko.String("upload.provider")
- // Static URLS.
- // url.com/subscription/{campaign_uuid}/{subscriber_uuid}
- c.UnsubURL = fmt.Sprintf("%s/subscription/%%s/%%s", c.RootURL)
- // url.com/subscription/optin/{subscriber_uuid}
- c.OptinURL = fmt.Sprintf("%s/subscription/optin/%%s?%%s", c.RootURL)
- // url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
- c.LinkTrackURL = fmt.Sprintf("%s/link/%%s/%%s/%%s", c.RootURL)
- // url.com/link/{campaign_uuid}/{subscriber_uuid}
- c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL)
- // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
- c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
- c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled")
- c.BounceSESEnabled = ko.Bool("bounce.ses_enabled")
- c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
- return &c
- }
- // initI18n initializes a new i18n instance with the selected language map
- // loaded from the filesystem. English is a loaded first as the default map
- // and then the selected language is loaded on top of it so that if there are
- // missing translations in it, the default English translations show up.
- func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
- i, ok, err := getI18nLang(lang, fs)
- if err != nil {
- if ok {
- lo.Println(err)
- } else {
- lo.Fatal(err)
- }
- }
- return i
- }
- // initCampaignManager initializes the campaign manager.
- func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
- campNotifCB := func(subject string, data interface{}) error {
- return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data)
- }
- if ko.Int("app.concurrency") < 1 {
- lo.Fatal("app.concurrency should be at least 1")
- }
- if ko.Int("app.message_rate") < 1 {
- lo.Fatal("app.message_rate should be at least 1")
- }
- return manager.New(manager.Config{
- BatchSize: ko.Int("app.batch_size"),
- Concurrency: ko.Int("app.concurrency"),
- MessageRate: ko.Int("app.message_rate"),
- MaxSendErrors: ko.Int("app.max_send_errors"),
- FromEmail: cs.FromEmail,
- IndividualTracking: ko.Bool("privacy.individual_tracking"),
- UnsubURL: cs.UnsubURL,
- OptinURL: cs.OptinURL,
- LinkTrackURL: cs.LinkTrackURL,
- ViewTrackURL: cs.ViewTrackURL,
- MessageURL: cs.MessageURL,
- UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
- SlidingWindow: ko.Bool("app.message_sliding_window"),
- SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
- SlidingWindowRate: ko.Int("app.message_sliding_window_rate"),
- }, newManagerStore(q), campNotifCB, app.i18n, lo)
- }
- // initImporter initializes the bulk subscriber importer.
- func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
- return subimporter.New(
- subimporter.Options{
- UpsertStmt: q.UpsertSubscriber.Stmt,
- BlocklistStmt: q.UpsertBlocklistSubscriber.Stmt,
- UpdateListDateStmt: q.UpdateListsDate.Stmt,
- NotifCB: func(subject string, data interface{}) error {
- app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data)
- return nil
- },
- }, db.DB)
- }
- // initSMTPMessenger initializes the SMTP messenger.
- func initSMTPMessenger(m *manager.Manager) messenger.Messenger {
- var (
- mapKeys = ko.MapKeys("smtp")
- servers = make([]email.Server, 0, len(mapKeys))
- )
- items := ko.Slices("smtp")
- if len(items) == 0 {
- lo.Fatalf("no SMTP servers found in config")
- }
- // Load the config for multipme SMTP servers.
- for _, item := range items {
- if !item.Bool("enabled") {
- continue
- }
- // Read the SMTP config.
- var s email.Server
- if err := item.UnmarshalWithConf("", &s, koanf.UnmarshalConf{Tag: "json"}); err != nil {
- lo.Fatalf("error reading SMTP config: %v", err)
- }
- servers = append(servers, s)
- lo.Printf("loaded email (SMTP) messenger: %s@%s",
- item.String("username"), item.String("host"))
- }
- if len(servers) == 0 {
- lo.Fatalf("no SMTP servers enabled in settings")
- }
- // Initialize the e-mail messenger with multiple SMTP servers.
- msgr, err := email.New(servers...)
- if err != nil {
- lo.Fatalf("error loading e-mail messenger: %v", err)
- }
- return msgr
- }
- // initPostbackMessengers initializes and returns all the enabled
- // HTTP postback messenger backends.
- func initPostbackMessengers(m *manager.Manager) []messenger.Messenger {
- items := ko.Slices("messengers")
- if len(items) == 0 {
- return nil
- }
- var out []messenger.Messenger
- for _, item := range items {
- if !item.Bool("enabled") {
- continue
- }
- // Read the Postback server config.
- var (
- name = item.String("name")
- o postback.Options
- )
- if err := item.UnmarshalWithConf("", &o, koanf.UnmarshalConf{Tag: "json"}); err != nil {
- lo.Fatalf("error reading Postback config: %v", err)
- }
- // Initialize the Messenger.
- p, err := postback.New(o)
- if err != nil {
- lo.Fatalf("error initializing Postback messenger %s: %v", name, err)
- }
- out = append(out, p)
- lo.Printf("loaded Postback messenger: %s", name)
- }
- return out
- }
- // initMediaStore initializes Upload manager with a custom backend.
- func initMediaStore() media.Store {
- switch provider := ko.String("upload.provider"); provider {
- case "s3":
- var o s3.Opt
- ko.Unmarshal("upload.s3", &o)
- up, err := s3.NewS3Store(o)
- if err != nil {
- lo.Fatalf("error initializing s3 upload provider %s", err)
- }
- lo.Println("media upload provider: s3")
- return up
- case "filesystem":
- var o filesystem.Opts
- ko.Unmarshal("upload.filesystem", &o)
- o.RootURL = ko.String("app.root_url")
- o.UploadPath = filepath.Clean(o.UploadPath)
- o.UploadURI = filepath.Clean(o.UploadURI)
- up, err := filesystem.NewDiskStore(o)
- if err != nil {
- lo.Fatalf("error initializing filesystem upload provider %s", err)
- }
- lo.Println("media upload provider: filesystem")
- return up
- default:
- lo.Fatalf("unknown provider. select filesystem or s3")
- }
- return nil
- }
- // initNotifTemplates compiles and returns e-mail notification templates that are
- // used for sending ad-hoc notifications to admins and subscribers.
- func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *template.Template {
- // Register utility functions that the e-mail templates can use.
- funcs := template.FuncMap{
- "RootURL": func() string {
- return cs.RootURL
- },
- "LogoURL": func() string {
- return cs.LogoURL
- },
- "L": func() *i18n.I18n {
- return i
- },
- }
- tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
- if err != nil {
- lo.Fatalf("error parsing e-mail notif templates: %v", err)
- }
- return tpl
- }
- // initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks
- // for incoming bounce events.
- func initBounceManager(app *App) *bounce.Manager {
- opt := bounce.Opt{
- BounceCount: ko.MustInt("bounce.count"),
- BounceAction: ko.MustString("bounce.action"),
- WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
- SESEnabled: ko.Bool("bounce.ses_enabled"),
- SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
- SendgridKey: ko.String("bounce.sendgrid_key"),
- }
- // For now, only one mailbox is supported.
- for _, b := range ko.Slices("bounce.mailboxes") {
- if !b.Bool("enabled") {
- continue
- }
- var boxOpt mailbox.Opt
- if err := b.UnmarshalWithConf("", &boxOpt, koanf.UnmarshalConf{Tag: "json"}); err != nil {
- lo.Fatalf("error reading bounce mailbox config: %v", err)
- }
- opt.MailboxType = b.String("type")
- opt.MailboxEnabled = true
- opt.Mailbox = boxOpt
- break
- }
- b, err := bounce.New(opt, &bounce.Queries{
- RecordQuery: app.queries.RecordBounce,
- }, app.log)
- if err != nil {
- lo.Fatalf("error initializing bounce manager: %v", err)
- }
- return b
- }
- // initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
- func initHTTPServer(app *App) *echo.Echo {
- // Initialize the HTTP server.
- var srv = echo.New()
- srv.HideBanner = true
- // Register app (*App) to be injected into all HTTP handlers.
- srv.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- c.Set("app", app)
- return next(c)
- }
- })
- // Parse and load user facing templates.
- tpl, err := stuffbin.ParseTemplatesGlob(template.FuncMap{
- "L": func() *i18n.I18n {
- return app.i18n
- }}, app.fs, "/public/templates/*.html")
- if err != nil {
- lo.Fatalf("error parsing public templates: %v", err)
- }
- srv.Renderer = &tplRenderer{
- templates: tpl,
- RootURL: app.constants.RootURL,
- LogoURL: app.constants.LogoURL,
- FaviconURL: app.constants.FaviconURL}
- // Initialize the static file server.
- fSrv := app.fs.FileServer()
- // Public (subscriber) facing static files.
- srv.GET("/public/*", echo.WrapHandler(fSrv))
- // Admin (frontend) facing static files.
- srv.GET("/admin/static/*", echo.WrapHandler(fSrv))
- // Public (subscriber) facing media upload files.
- if ko.String("upload.provider") == "filesystem" {
- srv.Static(ko.String("upload.filesystem.upload_uri"),
- ko.String("upload.filesystem.upload_path"))
- }
- // Register all HTTP handlers.
- initHTTPHandlers(srv, app)
- // Start the server.
- go func() {
- if err := srv.Start(ko.String("app.address")); err != nil {
- if strings.Contains(err.Error(), "Server closed") {
- lo.Println("HTTP server shut down")
- } else {
- lo.Fatalf("error starting HTTP server: %v", err)
- }
- }
- }()
- return srv
- }
- func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) chan bool {
- // The blocking signal handler that main() waits on.
- out := make(chan bool)
- // Respawn a new process and exit the running one.
- respawn := func() {
- if err := syscall.Exec(os.Args[0], os.Args, os.Environ()); err != nil {
- lo.Fatalf("error spawning process: %v", err)
- }
- os.Exit(0)
- }
- // Listen for reload signal.
- go func() {
- for range sigChan {
- lo.Println("reloading on signal ...")
- go closer()
- select {
- case <-closerWait:
- // Wait for the closer to finish.
- respawn()
- case <-time.After(time.Second * 3):
- // Or timeout and force close.
- respawn()
- }
- }
- }()
- return out
- }
- func joinFSPaths(root string, paths []string) []string {
- out := make([]string, 0, len(paths))
- for _, p := range paths {
- // real_path:stuffbin_alias
- f := strings.Split(p, ":")
- out = append(out, path.Join(root, f[0])+":"+f[1])
- }
- return out
- }
|