init.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "html/template"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "syscall"
  10. "time"
  11. "github.com/jmoiron/sqlx"
  12. "github.com/jmoiron/sqlx/types"
  13. "github.com/knadh/goyesql/v2"
  14. goyesqlx "github.com/knadh/goyesql/v2/sqlx"
  15. "github.com/knadh/koanf"
  16. "github.com/knadh/koanf/maps"
  17. "github.com/knadh/koanf/parsers/toml"
  18. "github.com/knadh/koanf/providers/confmap"
  19. "github.com/knadh/koanf/providers/file"
  20. "github.com/knadh/koanf/providers/posflag"
  21. "github.com/knadh/listmonk/internal/i18n"
  22. "github.com/knadh/listmonk/internal/manager"
  23. "github.com/knadh/listmonk/internal/media"
  24. "github.com/knadh/listmonk/internal/media/providers/filesystem"
  25. "github.com/knadh/listmonk/internal/media/providers/s3"
  26. "github.com/knadh/listmonk/internal/messenger"
  27. "github.com/knadh/listmonk/internal/messenger/email"
  28. "github.com/knadh/listmonk/internal/messenger/postback"
  29. "github.com/knadh/listmonk/internal/subimporter"
  30. "github.com/knadh/stuffbin"
  31. "github.com/labstack/echo"
  32. flag "github.com/spf13/pflag"
  33. )
  34. const (
  35. queryFilePath = "queries.sql"
  36. )
  37. // constants contains static, constant config values required by the app.
  38. type constants struct {
  39. RootURL string `koanf:"root_url"`
  40. LogoURL string `koanf:"logo_url"`
  41. FaviconURL string `koanf:"favicon_url"`
  42. FromEmail string `koanf:"from_email"`
  43. NotifyEmails []string `koanf:"notify_emails"`
  44. Lang string `koanf:"lang"`
  45. DBBatchSize int `koanf:"batch_size"`
  46. Privacy struct {
  47. IndividualTracking bool `koanf:"individual_tracking"`
  48. AllowBlocklist bool `koanf:"allow_blocklist"`
  49. AllowExport bool `koanf:"allow_export"`
  50. AllowWipe bool `koanf:"allow_wipe"`
  51. Exportable map[string]bool `koanf:"-"`
  52. } `koanf:"privacy"`
  53. AdminUsername []byte `koanf:"admin_username"`
  54. AdminPassword []byte `koanf:"admin_password"`
  55. UnsubURL string
  56. LinkTrackURL string
  57. ViewTrackURL string
  58. OptinURL string
  59. MessageURL string
  60. MediaProvider string
  61. }
  62. func initFlags() {
  63. f := flag.NewFlagSet("config", flag.ContinueOnError)
  64. f.Usage = func() {
  65. // Register --help handler.
  66. fmt.Println(f.FlagUsages())
  67. os.Exit(0)
  68. }
  69. // Register the commandline flags.
  70. f.StringSlice("config", []string{"config.toml"},
  71. "path to one or more config files (will be merged in order)")
  72. f.Bool("install", false, "run first time installation")
  73. f.Bool("upgrade", false, "upgrade database to the current version")
  74. f.Bool("version", false, "current version of the build")
  75. f.Bool("new-config", false, "generate sample config file")
  76. f.String("static-dir", "", "(optional) path to directory with static files")
  77. f.Bool("yes", false, "assume 'yes' to prompts, eg: during --install")
  78. if err := f.Parse(os.Args[1:]); err != nil {
  79. lo.Fatalf("error loading flags: %v", err)
  80. }
  81. if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
  82. lo.Fatalf("error loading config: %v", err)
  83. }
  84. }
  85. // initConfigFiles loads the given config files into the koanf instance.
  86. func initConfigFiles(files []string, ko *koanf.Koanf) {
  87. for _, f := range files {
  88. lo.Printf("reading config: %s", f)
  89. if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
  90. if os.IsNotExist(err) {
  91. lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
  92. }
  93. lo.Fatalf("error loadng config from file: %v.", err)
  94. }
  95. }
  96. }
  97. // initFileSystem initializes the stuffbin FileSystem to provide
  98. // access to bunded static assets to the app.
  99. func initFS(staticDir string) stuffbin.FileSystem {
  100. // Get the executable's path.
  101. path, err := os.Executable()
  102. if err != nil {
  103. lo.Fatalf("error getting executable path: %v", err)
  104. }
  105. // Load the static files stuffed in the binary.
  106. fs, err := stuffbin.UnStuff(path)
  107. if err != nil {
  108. // Running in local mode. Load local assets into
  109. // the in-memory stuffbin.FileSystem.
  110. lo.Printf("unable to initialize embedded filesystem: %v", err)
  111. lo.Printf("using local filesystem for static assets")
  112. files := []string{
  113. "config.toml.sample",
  114. "queries.sql",
  115. "schema.sql",
  116. "static/email-templates",
  117. // Alias /static/public to /public for the HTTP fileserver.
  118. "static/public:/public",
  119. // The frontend app's static assets are aliased to /frontend
  120. // so that they are accessible at /frontend/js/* etc.
  121. // Alias all files inside dist/ and dist/frontend to frontend/*.
  122. "frontend/dist/favicon.png:/frontend/favicon.png",
  123. "frontend/dist/frontend:/frontend",
  124. "i18n:/i18n",
  125. }
  126. fs, err = stuffbin.NewLocalFS("/", files...)
  127. if err != nil {
  128. lo.Fatalf("failed to initialize local file for assets: %v", err)
  129. }
  130. }
  131. // Optional static directory to override files.
  132. if staticDir != "" {
  133. lo.Printf("loading static files from: %v", staticDir)
  134. fStatic, err := stuffbin.NewLocalFS("/", []string{
  135. filepath.Join(staticDir, "/email-templates") + ":/static/email-templates",
  136. // Alias /static/public to /public for the HTTP fileserver.
  137. filepath.Join(staticDir, "/public") + ":/public",
  138. }...)
  139. if err != nil {
  140. lo.Fatalf("failed reading static directory: %s: %v", staticDir, err)
  141. }
  142. if err := fs.Merge(fStatic); err != nil {
  143. lo.Fatalf("error merging static directory: %s: %v", staticDir, err)
  144. }
  145. }
  146. return fs
  147. }
  148. // initDB initializes the main DB connection pool and parse and loads the app's
  149. // SQL queries into a prepared query map.
  150. func initDB() *sqlx.DB {
  151. var dbCfg dbConf
  152. if err := ko.Unmarshal("db", &dbCfg); err != nil {
  153. lo.Fatalf("error loading db config: %v", err)
  154. }
  155. lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
  156. db, err := connectDB(dbCfg)
  157. if err != nil {
  158. lo.Fatalf("error connecting to DB: %v", err)
  159. }
  160. return db
  161. }
  162. // initQueries loads named SQL queries from the queries file and optionally
  163. // prepares them.
  164. func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQueries bool) (goyesql.Queries, *Queries) {
  165. // Load SQL queries.
  166. qB, err := fs.Read(sqlFile)
  167. if err != nil {
  168. lo.Fatalf("error reading SQL file %s: %v", sqlFile, err)
  169. }
  170. qMap, err := goyesql.ParseBytes(qB)
  171. if err != nil {
  172. lo.Fatalf("error parsing SQL queries: %v", err)
  173. }
  174. if !prepareQueries {
  175. return qMap, nil
  176. }
  177. // Prepare queries.
  178. var q Queries
  179. if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
  180. lo.Fatalf("error preparing SQL queries: %v", err)
  181. }
  182. return qMap, &q
  183. }
  184. // initSettings loads settings from the DB.
  185. func initSettings(q *Queries) {
  186. var s types.JSONText
  187. if err := q.GetSettings.Get(&s); err != nil {
  188. lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err))
  189. }
  190. // Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
  191. // nested maps {app: {favicon_url}}.
  192. var out map[string]interface{}
  193. if err := json.Unmarshal(s, &out); err != nil {
  194. lo.Fatalf("error unmarshalling settings from DB: %v", err)
  195. }
  196. if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
  197. lo.Fatalf("error parsing settings from DB: %v", err)
  198. }
  199. }
  200. func initConstants() *constants {
  201. // Read constants.
  202. var c constants
  203. if err := ko.Unmarshal("app", &c); err != nil {
  204. lo.Fatalf("error loading app config: %v", err)
  205. }
  206. if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
  207. lo.Fatalf("error loading app config: %v", err)
  208. }
  209. c.RootURL = strings.TrimRight(c.RootURL, "/")
  210. c.Lang = ko.String("app.lang")
  211. c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
  212. c.MediaProvider = ko.String("upload.provider")
  213. // Static URLS.
  214. // url.com/subscription/{campaign_uuid}/{subscriber_uuid}
  215. c.UnsubURL = fmt.Sprintf("%s/subscription/%%s/%%s", c.RootURL)
  216. // url.com/subscription/optin/{subscriber_uuid}
  217. c.OptinURL = fmt.Sprintf("%s/subscription/optin/%%s?%%s", c.RootURL)
  218. // url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
  219. c.LinkTrackURL = fmt.Sprintf("%s/link/%%s/%%s/%%s", c.RootURL)
  220. // url.com/link/{campaign_uuid}/{subscriber_uuid}
  221. c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL)
  222. // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
  223. c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
  224. return &c
  225. }
  226. // initI18n initializes a new i18n instance with the selected language map
  227. // loaded from the filesystem.
  228. func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18nLang {
  229. b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
  230. if err != nil {
  231. lo.Fatalf("error loading i18n language file: %v", err)
  232. }
  233. i, err := i18n.New(lang, b)
  234. if err != nil {
  235. lo.Fatalf("error unmarshalling i18n language: %v", err)
  236. }
  237. return i
  238. }
  239. // initCampaignManager initializes the campaign manager.
  240. func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
  241. campNotifCB := func(subject string, data interface{}) error {
  242. return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data)
  243. }
  244. if ko.Int("app.concurrency") < 1 {
  245. lo.Fatal("app.concurrency should be at least 1")
  246. }
  247. if ko.Int("app.message_rate") < 1 {
  248. lo.Fatal("app.message_rate should be at least 1")
  249. }
  250. return manager.New(manager.Config{
  251. BatchSize: ko.Int("app.batch_size"),
  252. Concurrency: ko.Int("app.concurrency"),
  253. MessageRate: ko.Int("app.message_rate"),
  254. MaxSendErrors: ko.Int("app.max_send_errors"),
  255. FromEmail: cs.FromEmail,
  256. IndividualTracking: ko.Bool("privacy.individual_tracking"),
  257. UnsubURL: cs.UnsubURL,
  258. OptinURL: cs.OptinURL,
  259. LinkTrackURL: cs.LinkTrackURL,
  260. ViewTrackURL: cs.ViewTrackURL,
  261. MessageURL: cs.MessageURL,
  262. UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
  263. }, newManagerDB(q), campNotifCB, lo)
  264. }
  265. // initImporter initializes the bulk subscriber importer.
  266. func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
  267. return subimporter.New(
  268. subimporter.Options{
  269. UpsertStmt: q.UpsertSubscriber.Stmt,
  270. BlocklistStmt: q.UpsertBlocklistSubscriber.Stmt,
  271. UpdateListDateStmt: q.UpdateListsDate.Stmt,
  272. NotifCB: func(subject string, data interface{}) error {
  273. app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data)
  274. return nil
  275. },
  276. }, db.DB)
  277. }
  278. // initSMTPMessenger initializes the SMTP messenger.
  279. func initSMTPMessenger(m *manager.Manager) messenger.Messenger {
  280. var (
  281. mapKeys = ko.MapKeys("smtp")
  282. servers = make([]email.Server, 0, len(mapKeys))
  283. )
  284. items := ko.Slices("smtp")
  285. if len(items) == 0 {
  286. lo.Fatalf("no SMTP servers found in config")
  287. }
  288. // Load the config for multipme SMTP servers.
  289. for _, item := range items {
  290. if !item.Bool("enabled") {
  291. continue
  292. }
  293. // Read the SMTP config.
  294. var s email.Server
  295. if err := item.UnmarshalWithConf("", &s, koanf.UnmarshalConf{Tag: "json"}); err != nil {
  296. lo.Fatalf("error reading SMTP config: %v", err)
  297. }
  298. servers = append(servers, s)
  299. lo.Printf("loaded email (SMTP) messenger: %s@%s",
  300. item.String("username"), item.String("host"))
  301. }
  302. if len(servers) == 0 {
  303. lo.Fatalf("no SMTP servers enabled in settings")
  304. }
  305. // Initialize the e-mail messenger with multiple SMTP servers.
  306. msgr, err := email.New(servers...)
  307. if err != nil {
  308. lo.Fatalf("error loading e-mail messenger: %v", err)
  309. }
  310. return msgr
  311. }
  312. // initPostbackMessengers initializes and returns all the enabled
  313. // HTTP postback messenger backends.
  314. func initPostbackMessengers(m *manager.Manager) []messenger.Messenger {
  315. items := ko.Slices("messengers")
  316. if len(items) == 0 {
  317. return nil
  318. }
  319. var out []messenger.Messenger
  320. for _, item := range items {
  321. if !item.Bool("enabled") {
  322. continue
  323. }
  324. // Read the Postback server config.
  325. var (
  326. name = item.String("name")
  327. o postback.Options
  328. )
  329. if err := item.UnmarshalWithConf("", &o, koanf.UnmarshalConf{Tag: "json"}); err != nil {
  330. lo.Fatalf("error reading Postback config: %v", err)
  331. }
  332. // Initialize the Messenger.
  333. p, err := postback.New(o)
  334. if err != nil {
  335. lo.Fatalf("error initializing Postback messenger %s: %v", name, err)
  336. }
  337. out = append(out, p)
  338. lo.Printf("loaded Postback messenger: %s", name)
  339. }
  340. return out
  341. }
  342. // initMediaStore initializes Upload manager with a custom backend.
  343. func initMediaStore() media.Store {
  344. switch provider := ko.String("upload.provider"); provider {
  345. case "s3":
  346. var o s3.Opts
  347. ko.Unmarshal("upload.s3", &o)
  348. up, err := s3.NewS3Store(o)
  349. if err != nil {
  350. lo.Fatalf("error initializing s3 upload provider %s", err)
  351. }
  352. lo.Println("media upload provider: s3")
  353. return up
  354. case "filesystem":
  355. var o filesystem.Opts
  356. ko.Unmarshal("upload.filesystem", &o)
  357. o.RootURL = ko.String("app.root_url")
  358. o.UploadPath = filepath.Clean(o.UploadPath)
  359. o.UploadURI = filepath.Clean(o.UploadURI)
  360. up, err := filesystem.NewDiskStore(o)
  361. if err != nil {
  362. lo.Fatalf("error initializing filesystem upload provider %s", err)
  363. }
  364. lo.Println("media upload provider: filesystem")
  365. return up
  366. default:
  367. lo.Fatalf("unknown provider. select filesystem or s3")
  368. }
  369. return nil
  370. }
  371. // initNotifTemplates compiles and returns e-mail notification templates that are
  372. // used for sending ad-hoc notifications to admins and subscribers.
  373. func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18nLang, cs *constants) *template.Template {
  374. // Register utility functions that the e-mail templates can use.
  375. funcs := template.FuncMap{
  376. "RootURL": func() string {
  377. return cs.RootURL
  378. },
  379. "LogoURL": func() string {
  380. return cs.LogoURL
  381. },
  382. "L": func() *i18n.I18nLang {
  383. return i
  384. },
  385. }
  386. tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
  387. if err != nil {
  388. lo.Fatalf("error parsing e-mail notif templates: %v", err)
  389. }
  390. return tpl
  391. }
  392. // initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
  393. func initHTTPServer(app *App) *echo.Echo {
  394. // Initialize the HTTP server.
  395. var srv = echo.New()
  396. srv.HideBanner = true
  397. // Register app (*App) to be injected into all HTTP handlers.
  398. srv.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
  399. return func(c echo.Context) error {
  400. c.Set("app", app)
  401. return next(c)
  402. }
  403. })
  404. // Parse and load user facing templates.
  405. tpl, err := stuffbin.ParseTemplatesGlob(nil, app.fs, "/public/templates/*.html")
  406. if err != nil {
  407. lo.Fatalf("error parsing public templates: %v", err)
  408. }
  409. srv.Renderer = &tplRenderer{
  410. templates: tpl,
  411. RootURL: app.constants.RootURL,
  412. LogoURL: app.constants.LogoURL,
  413. FaviconURL: app.constants.FaviconURL}
  414. // Initialize the static file server.
  415. fSrv := app.fs.FileServer()
  416. srv.GET("/public/*", echo.WrapHandler(fSrv))
  417. srv.GET("/frontend/*", echo.WrapHandler(fSrv))
  418. if ko.String("upload.provider") == "filesystem" {
  419. srv.Static(ko.String("upload.filesystem.upload_uri"),
  420. ko.String("upload.filesystem.upload_path"))
  421. }
  422. // Register all HTTP handlers.
  423. registerHTTPHandlers(srv)
  424. // Start the server.
  425. go func() {
  426. if err := srv.Start(ko.String("app.address")); err != nil {
  427. if strings.Contains(err.Error(), "Server closed") {
  428. lo.Println("HTTP server shut down")
  429. } else {
  430. lo.Fatalf("error starting HTTP server: %v", err)
  431. }
  432. }
  433. }()
  434. return srv
  435. }
  436. func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) chan bool {
  437. // The blocking signal handler that main() waits on.
  438. out := make(chan bool)
  439. // Respawn a new process and exit the running one.
  440. respawn := func() {
  441. if err := syscall.Exec(os.Args[0], os.Args, os.Environ()); err != nil {
  442. lo.Fatalf("error spawning process: %v", err)
  443. }
  444. os.Exit(0)
  445. }
  446. // Listen for reload signal.
  447. go func() {
  448. for range sigChan {
  449. lo.Println("reloading on signal ...")
  450. go closer()
  451. select {
  452. case <-closerWait:
  453. // Wait for the closer to finish.
  454. respawn()
  455. case <-time.After(time.Second * 3):
  456. // Or timeout and force close.
  457. respawn()
  458. }
  459. }
  460. }()
  461. return out
  462. }