mirror of
				https://github.com/knadh/listmonk.git
				synced 2025-10-31 11:05:56 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			327 lines
		
	
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			327 lines
		
	
	
	
		
			9.5 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package main
 | |
| 
 | |
| import (
 | |
| 	"fmt"
 | |
| 	"html/template"
 | |
| 	"log"
 | |
| 	"os"
 | |
| 	"path/filepath"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/jmoiron/sqlx"
 | |
| 	"github.com/knadh/goyesql/v2"
 | |
| 	goyesqlx "github.com/knadh/goyesql/v2/sqlx"
 | |
| 	"github.com/knadh/koanf/maps"
 | |
| 	"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/subimporter"
 | |
| 	"github.com/knadh/stuffbin"
 | |
| 	"github.com/labstack/echo"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	queryFilePath = "queries.sql"
 | |
| )
 | |
| 
 | |
| // initFileSystem initializes the stuffbin FileSystem to provide
 | |
| // access to bunded static assets to the app.
 | |
| func initFS(staticDir string) stuffbin.FileSystem {
 | |
| 	// Get the executable's path.
 | |
| 	path, err := os.Executable()
 | |
| 	if err != nil {
 | |
| 		log.Fatalf("error getting executable path: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	// Load the static files stuffed in the binary.
 | |
| 	fs, err := stuffbin.UnStuff(path)
 | |
| 	if err != nil {
 | |
| 		// Running in local mode. Load local assets into
 | |
| 		// the in-memory stuffbin.FileSystem.
 | |
| 		lo.Printf("unable to initialize embedded filesystem: %v", err)
 | |
| 		lo.Printf("using local filesystem for static assets")
 | |
| 		files := []string{
 | |
| 			"config.toml.sample",
 | |
| 			"queries.sql",
 | |
| 			"schema.sql",
 | |
| 			"static/email-templates",
 | |
| 
 | |
| 			// Alias /static/public to /public for the HTTP fileserver.
 | |
| 			"static/public:/public",
 | |
| 
 | |
| 			// The frontend app's static assets are aliased to /frontend
 | |
| 			// so that they are accessible at localhost:port/frontend/static/ ...
 | |
| 			"frontend/build:/frontend",
 | |
| 		}
 | |
| 
 | |
| 		fs, err = stuffbin.NewLocalFS("/", files...)
 | |
| 		if err != nil {
 | |
| 			lo.Fatalf("failed to initialize local file for assets: %v", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Optional static directory to override files.
 | |
| 	if staticDir != "" {
 | |
| 		lo.Printf("loading static files from: %v", staticDir)
 | |
| 		fStatic, err := stuffbin.NewLocalFS("/", []string{
 | |
| 			filepath.Join(staticDir, "/email-templates") + ":/static/email-templates",
 | |
| 
 | |
| 			// Alias /static/public to /public for the HTTP fileserver.
 | |
| 			filepath.Join(staticDir, "/public") + ":/public",
 | |
| 		}...)
 | |
| 		if err != nil {
 | |
| 			lo.Fatalf("failed reading static directory: %s: %v", staticDir, err)
 | |
| 		}
 | |
| 
 | |
| 		if err := fs.Merge(fStatic); err != nil {
 | |
| 			lo.Fatalf("error merging static directory: %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 {
 | |
| 		log.Fatalf("error loading db config: %v", err)
 | |
| 	}
 | |
| 	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
 | |
| }
 | |
| 
 | |
| // constants contains static, constant config values required by the app.
 | |
| type constants struct {
 | |
| 	RootURL      string   `koanf:"root"`
 | |
| 	LogoURL      string   `koanf:"logo_url"`
 | |
| 	FaviconURL   string   `koanf:"favicon_url"`
 | |
| 	FromEmail    string   `koanf:"from_email"`
 | |
| 	NotifyEmails []string `koanf:"notify_emails"`
 | |
| 	Privacy      struct {
 | |
| 		AllowBlacklist bool            `koanf:"allow_blacklist"`
 | |
| 		AllowExport    bool            `koanf:"allow_export"`
 | |
| 		AllowWipe      bool            `koanf:"allow_wipe"`
 | |
| 		Exportable     map[string]bool `koanf:"-"`
 | |
| 	} `koanf:"privacy"`
 | |
| 
 | |
| 	UnsubURL     string
 | |
| 	LinkTrackURL string
 | |
| 	ViewTrackURL string
 | |
| 	OptinURL     string
 | |
| }
 | |
| 
 | |
| func initConstants() *constants {
 | |
| 	// Read constants.
 | |
| 	var c constants
 | |
| 	if err := ko.Unmarshal("app", &c); err != nil {
 | |
| 		log.Fatalf("error loading app config: %v", err)
 | |
| 	}
 | |
| 	if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
 | |
| 		log.Fatalf("error loading app config: %v", err)
 | |
| 	}
 | |
| 	c.RootURL = strings.TrimRight(c.RootURL, "/")
 | |
| 	c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
 | |
| 
 | |
| 	// 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/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
 | |
| 	c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
 | |
| 
 | |
| 	return &c
 | |
| }
 | |
| 
 | |
| // 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)
 | |
| 	}
 | |
| 	return manager.New(manager.Config{
 | |
| 		Concurrency:   ko.Int("app.concurrency"),
 | |
| 		MaxSendErrors: ko.Int("app.max_send_errors"),
 | |
| 		FromEmail:     cs.FromEmail,
 | |
| 		UnsubURL:      cs.UnsubURL,
 | |
| 		OptinURL:      cs.OptinURL,
 | |
| 		LinkTrackURL:  cs.LinkTrackURL,
 | |
| 		ViewTrackURL:  cs.ViewTrackURL,
 | |
| 	}, newManagerDB(q), campNotifCB, lo)
 | |
| 
 | |
| }
 | |
| 
 | |
| // initImporter initializes the bulk subscriber importer.
 | |
| func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
 | |
| 	return subimporter.New(q.UpsertSubscriber.Stmt,
 | |
| 		q.UpsertBlacklistSubscriber.Stmt,
 | |
| 		q.UpdateListsDate.Stmt,
 | |
| 		db.DB,
 | |
| 		func(subject string, data interface{}) error {
 | |
| 			app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data)
 | |
| 			return nil
 | |
| 		})
 | |
| }
 | |
| 
 | |
| // initMessengers initializes various messaging backends.
 | |
| func initMessengers(m *manager.Manager) messenger.Messenger {
 | |
| 	// Load SMTP configurations for the default e-mail Messenger.
 | |
| 	var (
 | |
| 		mapKeys = ko.MapKeys("smtp")
 | |
| 		srv     = make([]messenger.Server, 0, len(mapKeys))
 | |
| 	)
 | |
| 
 | |
| 	for _, name := range mapKeys {
 | |
| 		if !ko.Bool(fmt.Sprintf("smtp.%s.enabled", name)) {
 | |
| 			lo.Printf("skipped SMTP: %s", name)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		var s messenger.Server
 | |
| 		if err := ko.Unmarshal("smtp."+name, &s); err != nil {
 | |
| 			lo.Fatalf("error loading SMTP: %v", err)
 | |
| 		}
 | |
| 		s.Name = name
 | |
| 		s.SendTimeout *= time.Millisecond
 | |
| 		srv = append(srv, s)
 | |
| 
 | |
| 		lo.Printf("loaded SMTP: %s (%s@%s)", s.Name, s.Username, s.Host)
 | |
| 	}
 | |
| 
 | |
| 	// Initialize the default e-mail messenger.
 | |
| 	msgr, err := messenger.NewEmailer(srv...)
 | |
| 	if err != nil {
 | |
| 		lo.Fatalf("error loading e-mail messenger: %v", err)
 | |
| 	}
 | |
| 	if err := m.AddMessenger(msgr); err != nil {
 | |
| 		lo.Printf("error registering messenger %s", err)
 | |
| 	}
 | |
| 
 | |
| 	return msgr
 | |
| }
 | |
| 
 | |
| // initMediaStore initializes Upload manager with a custom backend.
 | |
| func initMediaStore() media.Store {
 | |
| 	switch provider := ko.String("upload.provider"); provider {
 | |
| 	case "s3":
 | |
| 		var opts s3.Opts
 | |
| 		ko.Unmarshal("upload.s3", &opts)
 | |
| 		uplder, err := s3.NewS3Store(opts)
 | |
| 		if err != nil {
 | |
| 			lo.Fatalf("error initializing s3 upload provider %s", err)
 | |
| 		}
 | |
| 		return uplder
 | |
| 
 | |
| 	case "filesystem":
 | |
| 		var opts filesystem.Opts
 | |
| 		ko.Unmarshal("upload.filesystem", &opts)
 | |
| 		opts.UploadPath = filepath.Clean(opts.UploadPath)
 | |
| 		opts.UploadURI = filepath.Clean(opts.UploadURI)
 | |
| 		uplder, err := filesystem.NewDiskStore(opts)
 | |
| 		if err != nil {
 | |
| 			lo.Fatalf("error initializing filesystem upload provider %s", err)
 | |
| 		}
 | |
| 		return uplder
 | |
| 
 | |
| 	default:
 | |
| 		lo.Fatalf("unknown provider. please select one of either 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, 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
 | |
| 		}}
 | |
| 
 | |
| 	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
 | |
| }
 | |
| 
 | |
| // initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
 | |
| func initHTTPServer(app *App) {
 | |
| 	// 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(nil, 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()
 | |
| 	srv.GET("/public/*", echo.WrapHandler(fSrv))
 | |
| 	srv.GET("/frontend/*", echo.WrapHandler(fSrv))
 | |
| 	if ko.String("upload.provider") == "filesystem" {
 | |
| 		srv.Static(ko.String("upload.filesystem.upload_uri"),
 | |
| 			ko.String("upload.filesystem.upload_path"))
 | |
| 	}
 | |
| 
 | |
| 	// Register all HTTP handlers.
 | |
| 	registerHTTPHandlers(srv)
 | |
| 
 | |
| 	// Start the server.
 | |
| 	srv.Logger.Fatal(srv.Start(ko.String("app.address")))
 | |
| }
 |