...

Source file src/codeberg.org/tslocum/sriracha/internal/server/server.go

Documentation: codeberg.org/tslocum/sriracha/internal/server

     1  // Package server is the Sriracha imageboard and forum server.
     2  package server
     3  
     4  import (
     5  	"context"
     6  	"crypto/md5"
     7  	"crypto/sha3"
     8  	"crypto/sha512"
     9  	"crypto/tls"
    10  	"embed"
    11  	"encoding/base64"
    12  	"flag"
    13  	"fmt"
    14  	"hash"
    15  	"html/template"
    16  	"image"
    17  	"io"
    18  	"io/fs"
    19  	"log"
    20  	"maps"
    21  	"net"
    22  	"net/http"
    23  	"net/smtp"
    24  	"net/url"
    25  	"os"
    26  	"os/signal"
    27  	"path"
    28  	"path/filepath"
    29  	"regexp"
    30  	"runtime/debug"
    31  	"slices"
    32  	"strings"
    33  	"sync"
    34  	"time"
    35  
    36  	"codeberg.org/tslocum/sriracha"
    37  	"codeberg.org/tslocum/sriracha/internal/database"
    38  	. "codeberg.org/tslocum/sriracha/model"
    39  	. "codeberg.org/tslocum/sriracha/util"
    40  	"github.com/fsnotify/fsnotify"
    41  	"github.com/jackc/pgx/v5/pgxpool"
    42  	"github.com/leonelquinteros/gotext"
    43  	"github.com/r3labs/diff/v3"
    44  	"golang.org/x/net/http2"
    45  	"golang.org/x/net/http2/h2c"
    46  	"golang.org/x/sys/unix"
    47  	"golang.org/x/text/language"
    48  	"golang.org/x/text/language/display"
    49  	"golang.org/x/text/message"
    50  	"gopkg.in/yaml.v3"
    51  )
    52  
    53  // SrirachaVersion is the version of the software. In official releases, this
    54  // variable is replaced during compilation with the version of the release.
    55  var SrirachaVersion = "DEV"
    56  
    57  //go:embed locale
    58  var localeFS embed.FS
    59  
    60  // Default server settings.
    61  const (
    62  	defaultServerSiteName     = "Sriracha"
    63  	defaultServerSiteHome     = "/"
    64  	defaultServerOekakiWidth  = 540
    65  	defaultServerOekakiHeight = 540
    66  	defaultServerRefresh      = 30
    67  )
    68  
    69  // defaultServerEmbeds is a list of default oEmbed services.
    70  var defaultServerEmbeds = [][2]string{
    71  	{"YouTube", "https://youtube.com/oembed?format=json&url=SRIRACHA_EMBED"},
    72  	{"Vimeo", "https://vimeo.com/api/oembed.json?url=SRIRACHA_EMBED"},
    73  	{"SoundCloud", "https://soundcloud.com/oembed?format=json&url=SRIRACHA_EMBED"},
    74  }
    75  
    76  // Banner options.
    77  const (
    78  	bannerOverboard = -1
    79  	bannerNews      = -2
    80  	bannerPages     = -3
    81  )
    82  
    83  // NewsOption represents a News setting option.
    84  type NewsOption int
    85  
    86  // News options.
    87  const (
    88  	NewsDisable      NewsOption = 0
    89  	NewsWriteToNews  NewsOption = 1
    90  	NewsWriteToIndex NewsOption = 2
    91  )
    92  
    93  type categoryInfo struct {
    94  	Name        string
    95  	Description string
    96  	Boards      []*Board
    97  	Recent      []*Post
    98  }
    99  
   100  type HashAlgorithm int8
   101  
   102  const (
   103  	AlgorithmSHA2 HashAlgorithm = 0
   104  	AlgorithmSHA3 HashAlgorithm = 1
   105  )
   106  
   107  const HashSize = 48 // Bytes.
   108  
   109  // ServerOptions represents server configuration options and related data.
   110  type ServerOptions struct {
   111  	SiteName         string
   112  	SiteHome         string
   113  	SiteIndex        bool
   114  	News             NewsOption
   115  	BoardIndex       bool
   116  	CAPTCHA          bool
   117  	Refresh          int
   118  	Uploads          []*UploadType
   119  	Embeds           [][2]string
   120  	OekakiWidth      int
   121  	OekakiHeight     int
   122  	Overboard        string
   123  	OverboardType    BoardType
   124  	OverboardThreads int
   125  	OverboardReplies int
   126  	Identifiers      bool
   127  	Locale           string
   128  	Locales          map[string]string
   129  	LocalesSorted    []string
   130  	Algorithm        HashAlgorithm
   131  	Access           map[string]string
   132  	Banners          map[int][]*Banner
   133  	Rules            map[int][]template.HTML
   134  	Categories       []*categoryInfo
   135  	Notifications    bool
   136  	DevMode          bool
   137  	FuncMaps         map[string]template.FuncMap
   138  }
   139  
   140  // DefaultLocaleName returns the name of the configured default locale.
   141  func (opt *ServerOptions) DefaultLocaleName() string {
   142  	if opt.Locale == "" || opt.Locale == "en" {
   143  		return "English"
   144  	}
   145  	name := opt.Locales[opt.Locale]
   146  	if name != "" {
   147  		return name
   148  	}
   149  	return opt.Locale
   150  }
   151  
   152  type cachedKeyword struct {
   153  	id int            // ID.
   154  	p  *regexp.Regexp // Pattern.
   155  	a  string         // Action.
   156  }
   157  
   158  // rebuildInfo contains information used to request rebuilding a thread.
   159  type rebuildInfo struct {
   160  	post *Post
   161  	wg   *sync.WaitGroup
   162  }
   163  
   164  const entriesPerPage = 25
   165  
   166  // Server is the Sriracha imageboard and forum server.
   167  type Server struct {
   168  	Boards []*Board
   169  
   170  	rangeBans map[*Ban]*regexp.Regexp
   171  
   172  	keywordCache map[int][]*cachedKeyword
   173  
   174  	config *Config
   175  	dbPool *pgxpool.Pool
   176  	opt    ServerOptions
   177  
   178  	importDatabases []*importInfo
   179  
   180  	tpl             *template.Template
   181  	original        *template.Template
   182  	customTemplates []string
   183  
   184  	notifications          []notification
   185  	notificationsPattern   *regexp.Regexp
   186  	notificationsWaitGroup sync.WaitGroup
   187  	shutdownNotifications  chan struct{}
   188  
   189  	rebuildQueue     chan *rebuildInfo
   190  	rebuildWaitGroup sync.WaitGroup
   191  	rebuildLock      sync.Mutex
   192  
   193  	httpClient *http.Client
   194  
   195  	httpServer  *http.Server
   196  	httpsServer *http.Server
   197  	httpsCert   *tls.Certificate
   198  
   199  	httpMaxRequestSize int64
   200  
   201  	msgPrinter *message.Printer
   202  
   203  	lock sync.Mutex
   204  }
   205  
   206  // NewServer returns a new server.
   207  func NewServer() *Server {
   208  	httpClient := &http.Client{
   209  		Timeout: 15 * time.Second,
   210  	}
   211  	return &Server{
   212  		keywordCache: make(map[int][]*cachedKeyword),
   213  		opt: ServerOptions{
   214  			Banners: make(map[int][]*Banner),
   215  			Rules:   make(map[int][]template.HTML),
   216  		},
   217  		shutdownNotifications: make(chan struct{}),
   218  		rebuildQueue:          make(chan *rebuildInfo),
   219  		httpClient:            httpClient,
   220  		msgPrinter:            message.NewPrinter(language.English),
   221  	}
   222  }
   223  
   224  // parseBuildInfo parses version control information embedded in the binary
   225  // during compilation. This is only used in unofficial releases.
   226  func (s *Server) parseBuildInfo() {
   227  	if SrirachaVersion == "" {
   228  		SrirachaVersion = "DEV"
   229  	} else if SrirachaVersion != "DEV" {
   230  		return
   231  	}
   232  	info, ok := debug.ReadBuildInfo()
   233  	if !ok {
   234  		return
   235  	}
   236  	buildTag := info.Main.Version
   237  	if buildTag != "" && buildTag[0] == 'v' {
   238  		SrirachaVersion = buildTag[1:]
   239  		firstHyphen := strings.IndexRune(SrirachaVersion, '-')
   240  		firstPlus := strings.IndexRune(SrirachaVersion, '+')
   241  		if firstHyphen == -1 && firstPlus == -1 {
   242  			return
   243  		}
   244  		if firstHyphen != -1 {
   245  			SrirachaVersion = SrirachaVersion[:firstHyphen]
   246  			firstPlus = strings.IndexRune(SrirachaVersion, '+')
   247  		}
   248  		if firstPlus != -1 {
   249  			SrirachaVersion = SrirachaVersion[:firstPlus]
   250  		}
   251  		SrirachaVersion += "-DEV"
   252  	}
   253  	for _, setting := range info.Settings {
   254  		if setting.Key == "vcs.revision" {
   255  			revision := setting.Value
   256  			if len(revision) > 10 {
   257  				revision = revision[:10]
   258  			}
   259  			SrirachaVersion += "-" + revision
   260  			return
   261  		}
   262  	}
   263  }
   264  
   265  // forbidden returns whether a user is forbidden from performing an accion.
   266  // When forbidden, an error page is written to the web request automatically.
   267  func (s *Server) forbidden(w http.ResponseWriter, data *templateData, action string) bool {
   268  	var required AccountRole
   269  	switch s.config.Access[action] {
   270  	case "mod":
   271  		required = RoleMod
   272  	case "admin":
   273  		required = RoleAdmin
   274  	case "super-admin":
   275  		required = RoleSuperAdmin
   276  	}
   277  	return data.forbidden(w, required)
   278  }
   279  
   280  // parseConfig parses a YAML configuration file.
   281  func (s *Server) parseConfig(configFile string) error {
   282  	buf, err := os.ReadFile(configFile)
   283  	if err != nil {
   284  		return err
   285  	}
   286  
   287  	config := &Config{
   288  		Access: make(map[string]string),
   289  	}
   290  	err = yaml.Unmarshal(buf, config)
   291  	if err != nil {
   292  		return err
   293  	}
   294  
   295  	// Copy data from obsolete field.
   296  	if config.HTTP == "" && config.Serve != "" {
   297  		config.HTTP = config.Serve
   298  	}
   299  
   300  	switch {
   301  	case config.Root == "":
   302  		return fmt.Errorf("root (lowercase!) must be set in %s to the root directory (where board files are written)", configFile)
   303  	case config.HTTP == "":
   304  		return fmt.Errorf("http (lowercase!) must be set in %s to the HTTP server listen address (hostname:port)", configFile)
   305  	case config.SaltData == "":
   306  		return fmt.Errorf("saltdata (lowercase!) must be set in %s to the one-way secure data hashing salt (a long string of random data which, once set, never changes)", configFile)
   307  	case config.SaltPass == "":
   308  		return fmt.Errorf("saltpass (lowercase!) must be set in %s to the two-way secure data hashing salt (a long string of random data which, once set, never changes)", configFile)
   309  	case config.SaltTrip == "":
   310  		return fmt.Errorf("salttrip (lowercase!) must be set in %s to the secure tripcode generation salt (a long string of random data which, once set, never changes)", configFile)
   311  	}
   312  
   313  	if config.DBURL == "" {
   314  		switch {
   315  		case config.Address == "":
   316  			return fmt.Errorf("address (lowercase!) must be set in %s to the database address (hostname:port)", configFile)
   317  		case config.Username == "":
   318  			return fmt.Errorf("username (lowercase!) must be set in %s to the database username", configFile)
   319  		case config.Password == "":
   320  			return fmt.Errorf("password (lowercase!) must be set in %s to the database password", configFile)
   321  		case config.DBName == "":
   322  			return fmt.Errorf("dbname (lowercase!) must be set in %s to the database name", configFile)
   323  		}
   324  	}
   325  
   326  	if config.HTTPS != "" {
   327  		switch {
   328  		case config.HTTPSCert == "":
   329  			return fmt.Errorf("to serve HTTPS connections, httpscert (lowercase!) must be set in %s to a certificate file path", configFile)
   330  		case config.HTTPSKey == "":
   331  			return fmt.Errorf("to serve HTTPS connections, httpskey (lowercase!) must be set in %s to a private key file path", configFile)
   332  		}
   333  	}
   334  
   335  	if config.Algorithm == "" {
   336  		config.Algorithm = "sha-2"
   337  	}
   338  	switch config.Algorithm {
   339  	case "sha-2":
   340  		s.opt.Algorithm = AlgorithmSHA2
   341  	case "sha-3":
   342  		s.opt.Algorithm = AlgorithmSHA3
   343  	default:
   344  		return fmt.Errorf("algorithm must be set to sha-3 or sha-2")
   345  	}
   346  
   347  	if config.Locale == "" {
   348  		config.Locale = "en"
   349  	}
   350  
   351  	if config.MailFrom != "" && ParseEmail(config.MailFrom) == "" {
   352  		return fmt.Errorf("mailfrom is not a valid email address: %s", config.MailFrom)
   353  	} else if config.MailReplyTo != "" && ParseEmail(config.MailReplyTo) == "" {
   354  		return fmt.Errorf("mailreplyto is not a valid email address: %s", config.MailReplyTo)
   355  	}
   356  
   357  	if config.Mentions <= 0 {
   358  		config.Mentions = 60
   359  	}
   360  	if config.Notifications <= 0 {
   361  		config.Notifications = 1440
   362  	}
   363  
   364  	defaultAccess := map[string]string{
   365  		"ban.add":         "mod",
   366  		"ban.shorten":     "admin",
   367  		"ban.lengthen":    "mod",
   368  		"ban.delete":      "admin",
   369  		"banfile.add":     "mod",
   370  		"banfile.delete":  "admin",
   371  		"banner.add":      "admin",
   372  		"banner.update":   "admin",
   373  		"banner.delete":   "super-admin",
   374  		"board.add":       "admin",
   375  		"board.update":    "admin",
   376  		"board.delete":    "super-admin",
   377  		"category.add":    "admin",
   378  		"category.update": "admin",
   379  		"category.delete": "super-admin",
   380  		"keyword.add":     "admin",
   381  		"keyword.update":  "admin",
   382  		"keyword.delete":  "admin",
   383  		"page.add":        "admin",
   384  		"page.update":     "admin",
   385  		"page.delete":     "admin",
   386  		"post.sticky":     "mod",
   387  		"post.lock":       "mod",
   388  		"post.move":       "mod",
   389  		"post.delete":     "mod",
   390  	}
   391  	validateAccess := func(name string, v string) error {
   392  		if _, ok := defaultAccess[name]; !ok && name != "default" {
   393  			return fmt.Errorf("access configuration contains unrecognized action %s", name)
   394  		}
   395  		switch v {
   396  		case "mod", "admin", "super-admin", "disable":
   397  			return nil
   398  		default:
   399  			return fmt.Errorf("action %s has unknown access level %s: must be 'mod', 'admin', 'super-admin' or 'disable'", name, v)
   400  		}
   401  	}
   402  	var defaultRequirement string
   403  	for name, v := range config.Access {
   404  		err = validateAccess(name, v)
   405  		if err != nil {
   406  			return fmt.Errorf("access configuration is invalid: %s", err)
   407  		} else if name == "default" {
   408  			defaultRequirement = v
   409  			delete(config.Access, name)
   410  		}
   411  	}
   412  	for name, v := range defaultAccess {
   413  		if config.Access[name] != "" {
   414  			continue
   415  		} else if defaultRequirement != "" {
   416  			config.Access[name] = defaultRequirement
   417  			continue
   418  		}
   419  		config.Access[name] = v
   420  	}
   421  
   422  	s.config = config
   423  
   424  	if s.config.MailDomains != "" {
   425  		s.notificationsPattern, err = regexp.Compile(s.config.MailDomains)
   426  		if err != nil {
   427  			return fmt.Errorf("failed to parse maildomains regular expression: %s", err)
   428  		}
   429  	}
   430  	return nil
   431  }
   432  
   433  // parseLocales parses locale files.
   434  func (s *Server) parseLocales() error {
   435  	return fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
   436  		if err != nil {
   437  			return err
   438  		} else if d.IsDir() || !strings.HasSuffix(p, ".po") {
   439  			return nil
   440  		}
   441  		id := filepath.Base(strings.TrimSuffix(p, ".po"))
   442  
   443  		buf, err := localeFS.ReadFile(fmt.Sprintf("locale/%s/%s.po", id, id))
   444  		if err != nil {
   445  			return fmt.Errorf("failed to load locale %s: %s", id, err)
   446  		}
   447  
   448  		po := gotext.NewPo()
   449  		po.Parse(buf)
   450  		gotext.GetStorage().AddTranslator(fmt.Sprintf("sriracha-%s", id), po)
   451  		return nil
   452  	})
   453  }
   454  
   455  // connectToMailServer connects to the configured mail server and returns a SMTP client.
   456  func (s *Server) connectToMailServer() (*smtp.Client, error) {
   457  	if s.config.MailAddress == "" {
   458  		return nil, nil // Email notifications are disabled.
   459  	}
   460  
   461  	// Parse hostname and set default port.
   462  	address := s.config.MailAddress
   463  	hostname, _, err := net.SplitHostPort(s.config.MailAddress)
   464  	if err != nil {
   465  		hostname = s.config.MailAddress
   466  		if strings.ContainsRune(s.config.MailAddress, ':') {
   467  			address = fmt.Sprintf("[%s]:25", address)
   468  		} else {
   469  			address = address + ":25"
   470  		}
   471  	}
   472  	tlsConfig := &tls.Config{
   473  		InsecureSkipVerify: s.config.MailInsecure,
   474  		ServerName:         hostname,
   475  	}
   476  
   477  	// Connect to mail server.
   478  	var conn net.Conn
   479  	if s.config.MailTLS {
   480  		conn, err = tls.Dial("tcp", address, tlsConfig)
   481  		if err != nil {
   482  			log.Fatalf("failed to connect to SMTP server with TLS enabled at %s: %s", address, err)
   483  		}
   484  	} else {
   485  		conn, err = net.Dial("tcp", address)
   486  		if err != nil {
   487  			log.Fatalf("failed to connect to SMTP server without TLS enabled at %s: %s", address, err)
   488  		}
   489  	}
   490  
   491  	// Initialize client,
   492  	client, err := smtp.NewClient(conn, hostname)
   493  	if err != nil {
   494  		conn.Close()
   495  		return nil, fmt.Errorf("failed to initialize SMTP client: %s", err)
   496  	}
   497  
   498  	// Upgrade to TLS connection when available.
   499  	if !s.config.MailTLS {
   500  		ok, _ := client.Extension("STARTTLS")
   501  		if ok {
   502  			err = client.StartTLS(tlsConfig)
   503  			if err != nil {
   504  				client.Close()
   505  				return nil, fmt.Errorf("failed to upgrade plain text connection to TLS even though support for it was advertised")
   506  			}
   507  		}
   508  	}
   509  
   510  	// Authenticate.
   511  	var auth smtp.Auth
   512  	switch s.config.MailAuth {
   513  	case "challenge":
   514  		auth = smtp.CRAMMD5Auth(s.config.MailUsername, s.config.MailPassword)
   515  	case "plain":
   516  		auth = smtp.PlainAuth("", s.config.MailUsername, s.config.MailPassword, hostname)
   517  	case "", "none":
   518  		// Do nothing.
   519  	default:
   520  		client.Close()
   521  		return nil, fmt.Errorf("unrecognized mailauth configuration value %s: must be challenge / plain / none", s.config.MailAuth)
   522  	}
   523  	if auth != nil {
   524  		err := client.Auth(auth)
   525  		if err != nil {
   526  			client.Close()
   527  			return nil, fmt.Errorf("failed to authenticate with SMTP server: %s", err)
   528  		}
   529  	}
   530  
   531  	// Send NOOP command to verify connection and authentication were successful.
   532  	if err = client.Noop(); err != nil {
   533  		client.Close()
   534  		return nil, fmt.Errorf("failed to verify SMTP server connection by sending NOOP command: %s", err)
   535  	}
   536  	return client, nil
   537  }
   538  
   539  // sendMail sends an email.
   540  func (s *Server) sendMail(client *smtp.Client, recipient string, subject string, message string) error {
   541  	// Build mail body.
   542  	var body []byte
   543  	if s.config.MailFrom != "" {
   544  		body = fmt.Appendf(body, "From: %s\n", s.config.MailFrom)
   545  	}
   546  	body = fmt.Appendf(body, "To: %s\nSubject: %s\n", recipient, subject)
   547  	if s.config.MailReplyTo != "" {
   548  		body = fmt.Appendf(body, "Reply-To: %s\n", s.config.MailReplyTo)
   549  	}
   550  	body = fmt.Appendf(body, "\n%s", message)
   551  
   552  	// Reset state.
   553  	if err := client.Reset(); err != nil {
   554  		return fmt.Errorf("failed to reset state: %s", err)
   555  	}
   556  
   557  	// Set "From" and "To" addresses.
   558  	if s.config.MailFrom != "" {
   559  		if err := client.Mail(s.config.MailFrom); err != nil {
   560  			return fmt.Errorf("failed to set from address: %s", err)
   561  		}
   562  	}
   563  	if err := client.Rcpt(recipient); err != nil {
   564  		return fmt.Errorf("failed to set recipient address: %s", err)
   565  	}
   566  
   567  	// Initiate data transfer.
   568  	wc, err := client.Data()
   569  	if err != nil {
   570  		return fmt.Errorf("failed to send DATA command: %s", err)
   571  	}
   572  
   573  	// Write mail body.
   574  	_, err = wc.Write(body)
   575  	if err != nil {
   576  		return fmt.Errorf("failed to write email body: %s", err)
   577  	}
   578  
   579  	// Complete data transfer.
   580  	err = wc.Close()
   581  	if err != nil {
   582  		return fmt.Errorf("failed to write email body: %s", err)
   583  	}
   584  	return nil
   585  }
   586  
   587  // begin acquires a database connection from the pool and starts a transaction.
   588  func (s *Server) begin() *database.DB {
   589  	return database.Begin(s.dbPool, s.config)
   590  }
   591  
   592  // loadServerConfig loads the server configuration and sets default values.
   593  func (s *Server) loadServerConfig() error {
   594  	db := s.begin()
   595  	defer db.Commit()
   596  
   597  	siteName := db.GetString("sitename")
   598  	if siteName == "" {
   599  		siteName = defaultServerSiteName
   600  	}
   601  	s.opt.SiteName = siteName
   602  
   603  	siteHome := db.GetString("sitehome")
   604  	if siteHome == "" {
   605  		siteHome = defaultServerSiteHome
   606  	}
   607  	s.opt.SiteHome = siteHome
   608  
   609  	siteIndex := db.GetString("siteindex")
   610  	s.opt.SiteIndex = siteIndex == "" || siteIndex == "1"
   611  
   612  	news := NewsOption(db.GetInt("news"))
   613  	if news == NewsDisable || news == NewsWriteToNews || news == NewsWriteToIndex {
   614  		s.opt.News = news
   615  	}
   616  
   617  	boardIndex := db.GetString("boardindex")
   618  	s.opt.BoardIndex = boardIndex == "" || boardIndex == "1"
   619  
   620  	s.opt.CAPTCHA = db.GetBool("captcha")
   621  
   622  	oekakiWidth := db.GetInt("oekakiwidth")
   623  	if oekakiWidth == 0 {
   624  		oekakiWidth = defaultServerOekakiWidth
   625  	}
   626  	s.opt.OekakiWidth = oekakiWidth
   627  
   628  	oekakiHeight := db.GetInt("oekakiheight")
   629  	if oekakiHeight == 0 {
   630  		oekakiHeight = defaultServerOekakiHeight
   631  	}
   632  	s.opt.OekakiHeight = oekakiHeight
   633  
   634  	if !db.HaveConfig("refresh") {
   635  		s.opt.Refresh = defaultServerRefresh
   636  	} else {
   637  		s.opt.Refresh = db.GetInt("refresh")
   638  	}
   639  
   640  	s.opt.Overboard = db.GetString("overboard")
   641  	s.opt.OverboardType = BoardType(db.GetInt("overboardtype"))
   642  	s.opt.OverboardThreads = db.GetInt("overboardthreads")
   643  	s.opt.OverboardReplies = db.GetInt("overboardreplies")
   644  
   645  	s.opt.Uploads = s.config.UploadTypes()
   646  
   647  	s.opt.Embeds = nil
   648  	if !db.HaveConfig("embeds") {
   649  		s.opt.Embeds = append(s.opt.Embeds, defaultServerEmbeds...)
   650  	} else {
   651  		embeds := db.GetMultiString("embeds")
   652  		for _, v := range embeds {
   653  			split := strings.SplitN(v, " ", 2)
   654  			if len(split) != 2 {
   655  				continue
   656  			}
   657  			s.opt.Embeds = append(s.opt.Embeds, [2]string{split[0], split[1]})
   658  		}
   659  	}
   660  	db.ClearBoardCache()
   661  	s.removeInvalidBoardOptions(db)
   662  
   663  	s.reloadBans(db)
   664  
   665  	s.opt.Identifiers = s.config.Identifiers
   666  
   667  	s.opt.Locale = s.config.Locale
   668  
   669  	s.opt.Locales = make(map[string]string)
   670  	english := display.English.Languages()
   671  	fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
   672  		if err != nil {
   673  			return err
   674  		} else if d.IsDir() || !strings.HasSuffix(p, ".po") {
   675  			return nil
   676  		}
   677  		id := filepath.Base(strings.TrimSuffix(p, ".po"))
   678  
   679  		name := id
   680  		tag, err := language.Parse(id)
   681  		if err == nil {
   682  			tagName := english.Name(tag)
   683  			if tagName != "" {
   684  				name = tagName
   685  			}
   686  
   687  			if s.opt.Locale == id {
   688  				s.msgPrinter = message.NewPrinter(tag)
   689  			}
   690  		}
   691  
   692  		s.opt.Locales[id] = name
   693  		return nil
   694  	})
   695  	s.opt.Locales["en@pirate"] = "Pirate English"
   696  	if s.opt.Locale != "" && s.opt.Locale != "en" {
   697  		s.opt.Locales["en"] = "English"
   698  	}
   699  	s.opt.LocalesSorted = slices.SortedFunc(maps.Keys(s.opt.Locales), func(s1, s2 string) int {
   700  		return strings.Compare(s.opt.Locales[s1], s.opt.Locales[s2])
   701  	})
   702  
   703  	templateFuncMaps = make(map[string]template.FuncMap)
   704  	templateFuncMaps[""] = newTemplateFuncMap(s.opt.Locale)
   705  	for id := range s.opt.Locales {
   706  		templateFuncMaps[id] = newTemplateFuncMap(id)
   707  	}
   708  
   709  	s.opt.Access = make(map[string]string)
   710  	maps.Copy(s.opt.Access, s.config.Access)
   711  	return nil
   712  }
   713  
   714  // setDefaultPluginConfig sets default plugin configuration values.
   715  func (s *Server) setDefaultPluginConfig() error {
   716  	db := s.begin()
   717  	defer db.Commit()
   718  
   719  	for i, info := range allPluginInfo {
   720  		db.Plugin = info.Name
   721  
   722  		for i, config := range info.Config {
   723  			if !db.HaveConfig(config.Name) {
   724  				db.SaveString(config.Name, config.Value)
   725  			} else {
   726  				info.Config[i].Value = db.GetString(config.Name)
   727  			}
   728  		}
   729  
   730  		p := allPlugins[i]
   731  		pUpdate, ok := p.(sriracha.PluginWithUpdate)
   732  		if ok {
   733  			for _, config := range info.Config {
   734  				pUpdate.Update(db, config.Name)
   735  			}
   736  		}
   737  	}
   738  	return nil
   739  }
   740  
   741  // loadPluginConfig loads plugin configuration options.
   742  func (s *Server) loadPluginConfig() error {
   743  	db := s.begin()
   744  	defer db.Commit()
   745  
   746  	for _, info := range allPluginInfo {
   747  		db.Plugin = info.Name
   748  		for i, c := range info.Config {
   749  			v := db.GetString(strings.ToLower(info.Name + "." + c.Name))
   750  			if v != "" {
   751  				info.Config[i].Value = v
   752  			}
   753  		}
   754  	}
   755  	db.Plugin = ""
   756  	return nil
   757  }
   758  
   759  // officialTemplateDir searches for the path to the official template directory and returns it.
   760  func (s *Server) officialTemplateDir() string {
   761  	officialDir := "internal/server/template"
   762  	_, err := os.Stat(officialDir)
   763  	if !os.IsNotExist(err) {
   764  		return officialDir
   765  	}
   766  	officialDir = "template"
   767  	_, err = os.Stat(officialDir)
   768  	if !os.IsNotExist(err) {
   769  		return officialDir
   770  	}
   771  	return ""
   772  }
   773  
   774  // validateTemplateConfig validates whether the official and custom template
   775  // directories are unique and accessible.
   776  func (s *Server) validateTemplateConfig(officialDir string) error {
   777  	if s.config.Template == "" {
   778  		return nil
   779  	}
   780  	_, err := os.Stat(s.config.Template)
   781  	if os.IsNotExist(err) {
   782  		return fmt.Errorf("custom template directory %s does not exist", s.config.Template)
   783  	}
   784  	officialTemplate, err := os.Stat(officialDir)
   785  	if err != nil {
   786  		return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
   787  	}
   788  	customTemplate, err := os.Stat(s.config.Template)
   789  	if err != nil {
   790  		return fmt.Errorf("custom template directory %s is inaccessible", s.config.Template)
   791  	}
   792  	if os.SameFile(officialTemplate, customTemplate) {
   793  		return fmt.Errorf("official templates and custom templates must be located in separate directories")
   794  	}
   795  	return nil
   796  }
   797  
   798  // parseTemplates parses official and custom templates. Provide an empty
   799  // officialDir to load official templates from the embedded file system.
   800  // Otherwise, official templates are loaded from disk. When customDir is set,
   801  // custom templates are loaded from disk.
   802  func (s *Server) parseTemplates(officialDir string, customDir string) error {
   803  	s.customTemplates = s.customTemplates[:0]
   804  	wrapError := func(name string, err error) error {
   805  		var source string
   806  		if !slices.Contains(s.customTemplates, name) {
   807  			source = "official"
   808  		} else {
   809  			source = "custom"
   810  		}
   811  		return fmt.Errorf("failed to parse %s template file %s: %s", source, name, err)
   812  	}
   813  	parseDir := func(dir string, custom bool) error {
   814  		entries, err := os.ReadDir(dir)
   815  		if err != nil {
   816  			return err
   817  		}
   818  		for _, f := range entries {
   819  			if !strings.HasSuffix(f.Name(), ".gohtml") {
   820  				continue
   821  			} else if custom {
   822  				s.customTemplates = append(s.customTemplates, f.Name())
   823  			}
   824  
   825  			buf, err := os.ReadFile(filepath.Join(dir, f.Name()))
   826  			if err != nil {
   827  				return wrapError(f.Name(), err)
   828  			}
   829  
   830  			_, err = s.tpl.New(f.Name()).Parse(string(buf))
   831  			if err != nil {
   832  				return wrapError(f.Name(), err)
   833  			}
   834  		}
   835  		return nil
   836  	}
   837  	if officialDir == "" {
   838  		s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
   839  
   840  		entries, err := templateFS.ReadDir("template")
   841  		if err != nil {
   842  			return err
   843  		}
   844  		for _, f := range entries {
   845  			if !strings.HasSuffix(f.Name(), ".gohtml") {
   846  				continue
   847  			}
   848  
   849  			buf, err := templateFS.ReadFile(filepath.Join("template", f.Name()))
   850  			if err != nil {
   851  				return wrapError(f.Name(), err)
   852  			}
   853  
   854  			_, err = s.tpl.New(f.Name()).Parse(string(buf))
   855  			if err != nil {
   856  				return wrapError(f.Name(), err)
   857  			}
   858  		}
   859  	} else {
   860  		s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
   861  
   862  		err := parseDir(officialDir, false)
   863  		if err != nil {
   864  			return err
   865  		}
   866  	}
   867  
   868  	if customDir != "" {
   869  		err := parseDir(customDir, true)
   870  		if err != nil {
   871  			return err
   872  		}
   873  	}
   874  
   875  	var err error
   876  	s.original, err = s.tpl.Clone()
   877  	return err
   878  }
   879  
   880  func (s *Server) _watchTemplates(officialDir string, watcher *fsnotify.Watcher) {
   881  	for {
   882  		select {
   883  		case event, ok := <-watcher.Events:
   884  			if !ok {
   885  				return
   886  			} else if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) {
   887  				continue
   888  			}
   889  			err := s.parseTemplates(officialDir, s.config.Template)
   890  			if err != nil {
   891  				log.Printf("failed to parse template files: %s", err)
   892  			}
   893  		case err, ok := <-watcher.Errors:
   894  			if !ok {
   895  				return
   896  			}
   897  			log.Printf("fsnotify error: %s", err)
   898  		}
   899  	}
   900  }
   901  
   902  // watchTemplates watches the official and custom template directories for changes.
   903  func (s *Server) watchTemplates(officialDir string) error {
   904  	watcher, err := fsnotify.NewWatcher()
   905  	if err != nil {
   906  		log.Fatal(err)
   907  	}
   908  	go s._watchTemplates(officialDir, watcher)
   909  
   910  	err = watcher.Add(officialDir)
   911  	if err == nil && s.config.Template != "" {
   912  		err = watcher.Add(s.config.Template)
   913  	}
   914  	return err
   915  }
   916  
   917  // log adds an entry to the audit log.
   918  func (s *Server) log(db *database.DB, account *Account, board *Board, action string, info string) {
   919  	user := "system"
   920  	if account != nil && account.ID != 0 {
   921  		if account.Role == RoleSuperAdmin || account.Role == RoleAdmin {
   922  			user = "admin"
   923  		} else {
   924  			user = "mod"
   925  		}
   926  	}
   927  	for _, handlerInfo := range allPluginAuditHandlers {
   928  		db.Plugin = handlerInfo.Name
   929  		err := handlerInfo.Handler(db, user, action, info)
   930  		if err != nil {
   931  			log.Fatalf("plugin %s failed to process audit event: %s", handlerInfo.Name, err)
   932  		}
   933  	}
   934  	db.Plugin = ""
   935  
   936  	db.AddLog(&Log{
   937  		Account: account,
   938  		Board:   board,
   939  		Message: action,
   940  		Changes: info,
   941  	})
   942  }
   943  
   944  // refreshBannerCache refreshes the banner cache.
   945  func (s *Server) refreshBannerCache(db *database.DB) {
   946  	banners := s.opt.Banners
   947  	for id := range banners {
   948  		banners[id] = banners[id][:0]
   949  	}
   950  
   951  	for _, banner := range db.AllBanners() {
   952  		for _, board := range banner.Boards {
   953  			banners[board.ID] = append(banners[board.ID], banner)
   954  		}
   955  		if banner.Overboard {
   956  			banners[bannerOverboard] = append(banners[bannerOverboard], banner)
   957  		}
   958  		if banner.News {
   959  			banners[bannerNews] = append(banners[bannerNews], banner)
   960  		}
   961  		if banner.Pages {
   962  			banners[bannerPages] = append(banners[bannerPages], banner)
   963  		}
   964  	}
   965  
   966  	for id := range banners {
   967  		if len(banners[id]) == 0 {
   968  			delete(banners, id)
   969  		}
   970  	}
   971  }
   972  
   973  // refreshMaxRequestSize refreshes the maximum HTTP request size.
   974  func (s *Server) refreshMaxRequestSize(db *database.DB) {
   975  	const megabyte = 1048576 // 1 MB.
   976  	var messageSize int64
   977  	var fileSize int64
   978  	for _, b := range db.AllBoards() {
   979  		msg := int64(b.MaxMessage)
   980  		if msg <= 0 {
   981  			msg = megabyte
   982  		}
   983  		if msg > messageSize {
   984  			messageSize = msg
   985  		}
   986  		if b.Files <= 0 {
   987  			continue
   988  		}
   989  		files := int64(b.Files)
   990  		if b.MaxSizeThread*files > fileSize {
   991  			fileSize = b.MaxSizeThread * files
   992  		}
   993  		if b.MaxSizeReply*files > fileSize {
   994  			fileSize = b.MaxSizeReply * files
   995  		}
   996  	}
   997  	s.httpMaxRequestSize = 10*megabyte + messageSize + fileSize // 10 MB + maximum message size + maximum total size of uploaded files.
   998  }
   999  
  1000  // refreshRulesCache refreshes the board rules cache.
  1001  func (s *Server) refreshRulesCache(db *database.DB) {
  1002  	rules := s.opt.Rules
  1003  	for id := range rules {
  1004  		rules[id] = rules[id][:0]
  1005  	}
  1006  
  1007  	for _, board := range db.AllBoards() {
  1008  		for _, info := range allPluginRulesHandlers {
  1009  			rulesHTML, err := info.Handler(db, board)
  1010  			if err != nil {
  1011  				log.Fatalf("failed to refresh rules cache: plugin %s encountered an error: %s", info.Name, err)
  1012  			}
  1013  			rulesText := strings.TrimSpace(string(rulesHTML))
  1014  			if rulesText == "" {
  1015  				continue
  1016  			}
  1017  			for _, rulesLine := range strings.Split(rulesText, "\n") {
  1018  				if rulesLine == "" {
  1019  					continue
  1020  				}
  1021  				rules[board.ID] = append(rules[board.ID], template.HTML(rulesLine))
  1022  			}
  1023  		}
  1024  	}
  1025  
  1026  	for id := range rules {
  1027  		if len(rules[id]) == 0 {
  1028  			delete(rules, id)
  1029  		}
  1030  	}
  1031  }
  1032  
  1033  // refreshKeywordCache refreshes the keyword cache.
  1034  func (s *Server) refreshKeywordCache(db *database.DB) {
  1035  	for boardID := range s.keywordCache {
  1036  		s.keywordCache[boardID] = s.keywordCache[boardID][:0]
  1037  	}
  1038  
  1039  	for _, k := range db.AllKeywords() {
  1040  		var err error
  1041  		kw := &cachedKeyword{
  1042  			id: k.ID,
  1043  			a:  k.Action,
  1044  		}
  1045  		kw.p, err = regexp.Compile(k.Text)
  1046  		if err != nil {
  1047  			log.Fatalf("failed to parse keyword %s as regular expression: %s", k.Text, err)
  1048  		}
  1049  		for _, board := range k.Boards {
  1050  			s.keywordCache[board.ID] = append(s.keywordCache[board.ID], kw)
  1051  		}
  1052  	}
  1053  
  1054  	for boardID := range s.keywordCache {
  1055  		if len(s.keywordCache[boardID]) == 0 {
  1056  			delete(s.keywordCache, boardID)
  1057  		}
  1058  	}
  1059  }
  1060  
  1061  func (s *Server) _processCategory(c *Category) {
  1062  	for _, cat := range c.Categories {
  1063  		s._processCategory(cat)
  1064  	}
  1065  	if len(c.Boards) == 0 {
  1066  		return
  1067  	}
  1068  	info := &categoryInfo{
  1069  		Name:        c.Name,
  1070  		Description: c.Description,
  1071  		Boards:      c.Boards,
  1072  	}
  1073  	s.opt.Categories = append(s.opt.Categories, info)
  1074  }
  1075  
  1076  // refreshCategoryCache refreshes the category cache.
  1077  func (s *Server) refreshCategoryCache(db *database.DB) {
  1078  	s.opt.Categories = s.opt.Categories[:0]
  1079  	for _, c := range db.AllCategories() {
  1080  		if c.Parent == nil {
  1081  			s._processCategory(c)
  1082  		}
  1083  	}
  1084  	// Create pseudo-category.
  1085  	if len(s.opt.Categories) == 0 {
  1086  		info := &categoryInfo{}
  1087  		for _, b := range db.AllBoards() {
  1088  			if b.Hide == HideIndex || b.Hide == HideEverywhere {
  1089  				continue
  1090  			}
  1091  			info.Boards = append(info.Boards, b)
  1092  		}
  1093  		s.opt.Categories = append(s.opt.Categories, info)
  1094  	}
  1095  }
  1096  
  1097  func (s *Server) refreshRecentPosts(db *database.DB) {
  1098  	for _, info := range s.opt.Categories {
  1099  		info.Recent = info.Recent[:0]
  1100  		for _, b := range info.Boards {
  1101  			info.Recent = append(info.Recent, db.LastPostByBoard(b))
  1102  		}
  1103  	}
  1104  }
  1105  
  1106  // deletePostFiles deletes files associated with a post.
  1107  func (s *Server) deletePostFiles(p *Post) {
  1108  	if p.Board == nil {
  1109  		return
  1110  	} else if p.ID != 0 && p.Parent == 0 {
  1111  		os.Remove(filepath.Join(s.config.Root, p.Board.Dir, "res", fmt.Sprintf("%d.html", p.ID)))
  1112  	}
  1113  
  1114  	if p.File == "" {
  1115  		return
  1116  	}
  1117  	srcPath := filepath.Join(s.config.Root, p.Board.Dir, "src", p.File)
  1118  	os.Remove(srcPath)
  1119  
  1120  	if p.Thumb == "" {
  1121  		return
  1122  	}
  1123  	thumbPath := filepath.Join(s.config.Root, p.Board.Dir, "thumb", p.Thumb)
  1124  	os.Remove(thumbPath)
  1125  }
  1126  
  1127  // deletePost deletes a post from the database as well as any associated files.
  1128  func (s *Server) deletePost(db *database.DB, p *Post) {
  1129  	posts := db.AllPostsInThread(p.ID, false)
  1130  	for _, post := range posts {
  1131  		s.deletePostFiles(post)
  1132  	}
  1133  
  1134  	db.DeletePost(p.ID)
  1135  }
  1136  
  1137  // httpResponse executes a HTTP request and returns the response.
  1138  func (s *Server) httpResponse(r *http.Request) (*http.Response, error) {
  1139  	r.Header.Set("User-Agent", "Sriracha imageboard and forum server")
  1140  	return s.httpClient.Do(r)
  1141  }
  1142  
  1143  // buildData returns a new template data instance.
  1144  func (s *Server) buildData(db *database.DB, w http.ResponseWriter, r *http.Request) *templateData {
  1145  	if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
  1146  		http.SetCookie(w, &http.Cookie{
  1147  			Name:  "sriracha_session",
  1148  			Value: "",
  1149  			Path:  "/",
  1150  		})
  1151  		http.Redirect(w, r, "/sriracha/", http.StatusFound)
  1152  		return s.newTemplateData()
  1153  	}
  1154  
  1155  	if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
  1156  		var failedLogin bool
  1157  		username := r.FormValue("username")
  1158  		if len(username) != 0 {
  1159  			failedLogin = true
  1160  			password := r.FormValue("password")
  1161  			if len(password) != 0 {
  1162  				if !s.opt.DevMode {
  1163  					// Verify CAPTCHA.
  1164  					var solved bool
  1165  					ipHash := s.hashIP(r)
  1166  					challenge := db.GetCAPTCHA(ipHash)
  1167  					if challenge != nil {
  1168  						solution := FormString(r, "captcha")
  1169  						if strings.ToLower(solution) == challenge.Text {
  1170  							solved = true
  1171  							db.DeleteCAPTCHA(ipHash)
  1172  							os.Remove(filepath.Join(s.config.Root, "captcha", challenge.Image+".png"))
  1173  						}
  1174  					}
  1175  					if !solved {
  1176  						data := s.newTemplateData()
  1177  						data.Info = "Invalid CAPTCHA."
  1178  						data.Template = "manage_error"
  1179  						return data
  1180  					}
  1181  				}
  1182  
  1183  				// Verify username and password.
  1184  				account := db.LoginAccount(username, password)
  1185  				if account != nil {
  1186  					http.SetCookie(w, &http.Cookie{
  1187  						Name:  "sriracha_session",
  1188  						Value: account.Session,
  1189  						Path:  "/",
  1190  					})
  1191  					data := s.newTemplateData()
  1192  					data.Account = account
  1193  					if s.config.ImportMode {
  1194  						data.Redirect(w, r, "/sriracha/import/")
  1195  					}
  1196  					return data
  1197  				}
  1198  			}
  1199  		}
  1200  		if failedLogin {
  1201  			data := s.newTemplateData()
  1202  			data.Info = "Invalid username or password."
  1203  			data.Template = "manage_error"
  1204  			return data
  1205  		}
  1206  	}
  1207  
  1208  	cookies := r.CookiesNamed("sriracha_session")
  1209  	if len(cookies) > 0 {
  1210  		account := db.AccountBySessionKey(cookies[0].Value)
  1211  		if account != nil {
  1212  			data := s.newTemplateData()
  1213  			data.Account = account
  1214  			return data
  1215  		}
  1216  	}
  1217  	return s.newTemplateData()
  1218  }
  1219  
  1220  // writeThread writes a thread res page to disk.
  1221  func (s *Server) writeThread(db *database.DB, board *Board, postID int) {
  1222  	posts := db.AllPostsInThread(postID, true)
  1223  	if len(posts) == 0 {
  1224  		return
  1225  	}
  1226  
  1227  	if board.Unique == 0 {
  1228  		board.Unique = db.UniqueUserPosts(board)
  1229  	}
  1230  
  1231  	writePath := filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("_%d.html", postID))
  1232  	filePath := filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("%d.html", postID))
  1233  
  1234  	f, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1235  	if err != nil {
  1236  		log.Fatal(err)
  1237  	}
  1238  
  1239  	data := s.newTemplateData()
  1240  	data.Board = board
  1241  	data.Boards = db.AllBoards()
  1242  	data.Threads = [][]*Post{posts}
  1243  	data.ReplyMode = postID
  1244  	data.Template = "board_page"
  1245  	data.execute(f)
  1246  
  1247  	f.Close()
  1248  	err = os.Rename(writePath, filePath)
  1249  	if err != nil {
  1250  		log.Fatal(err)
  1251  	}
  1252  }
  1253  
  1254  // writeBoardIndexes writes board index pages to disk.
  1255  func (s *Server) writeBoardIndexes(db *database.DB, board *Board) {
  1256  	if board.Unique == 0 {
  1257  		board.Unique = db.UniqueUserPosts(board)
  1258  	}
  1259  
  1260  	data := s.newTemplateData()
  1261  	data.Board = board
  1262  	data.Boards = db.AllBoards()
  1263  	data.ReplyMode = 1
  1264  	data.Template = "board_catalog"
  1265  
  1266  	threadInfo := db.AllThreads(board, true)
  1267  
  1268  	// Write catalog.
  1269  	if board.Type == TypeImageboard {
  1270  		writePath := filepath.Join(s.config.Root, board.Dir, "_catalog.html")
  1271  		filePath := filepath.Join(s.config.Root, board.Dir, "catalog.html")
  1272  
  1273  		catalogFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1274  		if err != nil {
  1275  			log.Fatal(err)
  1276  		}
  1277  
  1278  		for _, info := range threadInfo {
  1279  			thread := db.PostByID(info[0])
  1280  			thread.Replies = info[1]
  1281  			data.Threads = append(data.Threads, []*Post{thread})
  1282  		}
  1283  		data.execute(catalogFile)
  1284  
  1285  		catalogFile.Close()
  1286  		err = os.Rename(writePath, filePath)
  1287  		if err != nil {
  1288  			log.Fatal(err)
  1289  		}
  1290  	}
  1291  
  1292  	// Write indexes.
  1293  
  1294  	data.ReplyMode = 0
  1295  	data.Template = "board_page"
  1296  	data.Pages = pageCount(len(threadInfo), board.Threads)
  1297  	for page := 0; page < data.Pages; page++ {
  1298  		fileName := "index.html"
  1299  		if page > 0 {
  1300  			fileName = fmt.Sprintf("%d.html", page)
  1301  		}
  1302  
  1303  		writePath := filepath.Join(s.config.Root, board.Dir, "_"+fileName)
  1304  		filePath := filepath.Join(s.config.Root, board.Dir, fileName)
  1305  
  1306  		indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1307  		if err != nil {
  1308  			log.Fatal(err)
  1309  		}
  1310  
  1311  		start := page * board.Threads
  1312  		end := len(threadInfo)
  1313  		if board.Threads != 0 && end > start+board.Threads {
  1314  			end = start + board.Threads
  1315  		}
  1316  
  1317  		data.Threads = data.Threads[:0]
  1318  		for _, info := range threadInfo[start:end] {
  1319  			thread := db.PostByID(info[0])
  1320  			thread.Replies = info[1]
  1321  			posts := []*Post{thread}
  1322  			if board.Type == TypeImageboard {
  1323  				posts = append(posts, db.AllReplies(thread.ID, board.Replies, true)...)
  1324  			}
  1325  			data.Threads = append(data.Threads, posts)
  1326  		}
  1327  		data.Page = page
  1328  		data.execute(indexFile)
  1329  
  1330  		indexFile.Close()
  1331  		err = os.Rename(writePath, filePath)
  1332  		if err != nil {
  1333  			log.Fatal(err)
  1334  		}
  1335  	}
  1336  }
  1337  
  1338  // writeOverboard writes overboard pages to disk.
  1339  func (s *Server) writeOverboard(db *database.DB) {
  1340  	var overboardDir string
  1341  	if s.opt.Overboard != "/" {
  1342  		overboardDir = s.opt.Overboard
  1343  	}
  1344  
  1345  	overboard := &Board{
  1346  		ID:      -1,
  1347  		Type:    s.opt.OverboardType,
  1348  		Name:    gotext.Get("Overboard"),
  1349  		Dir:     overboardDir,
  1350  		Threads: s.opt.OverboardThreads,
  1351  		Replies: s.opt.OverboardReplies,
  1352  	}
  1353  
  1354  	data := s.newTemplateData()
  1355  	data.Board = overboard
  1356  	data.Boards = db.AllBoards()
  1357  	data.ReplyMode = 1
  1358  	data.Template = "board_catalog"
  1359  
  1360  	threadInfo := db.AllThreads(nil, true)
  1361  
  1362  	// Write catalog.
  1363  	if overboard.Type == TypeImageboard {
  1364  		writePath := filepath.Join(s.config.Root, overboardDir, "_catalog.html")
  1365  		filePath := filepath.Join(s.config.Root, overboardDir, "catalog.html")
  1366  
  1367  		catalogFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1368  		if err != nil {
  1369  			log.Fatal(err)
  1370  		}
  1371  
  1372  		for _, info := range threadInfo {
  1373  			thread := db.PostByID(info[0])
  1374  			thread.Replies = info[1]
  1375  			data.Threads = append(data.Threads, []*Post{thread})
  1376  		}
  1377  		data.execute(catalogFile)
  1378  
  1379  		catalogFile.Close()
  1380  		err = os.Rename(writePath, filePath)
  1381  		if err != nil {
  1382  			log.Fatal(err)
  1383  		}
  1384  	}
  1385  
  1386  	// Write indexes.
  1387  
  1388  	data.ReplyMode = 0
  1389  	data.Template = "board_page"
  1390  	data.Pages = pageCount(len(threadInfo), overboard.Threads)
  1391  	for page := 0; page < data.Pages; page++ {
  1392  		fileName := "index.html"
  1393  		if page > 0 {
  1394  			fileName = fmt.Sprintf("%d.html", page)
  1395  		}
  1396  
  1397  		writePath := filepath.Join(s.config.Root, overboardDir, "_"+fileName)
  1398  		filePath := filepath.Join(s.config.Root, overboardDir, fileName)
  1399  
  1400  		indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1401  		if err != nil {
  1402  			log.Fatal(err)
  1403  		}
  1404  
  1405  		start := page * overboard.Threads
  1406  		end := len(threadInfo)
  1407  		if overboard.Threads != 0 && end > start+overboard.Threads {
  1408  			end = start + overboard.Threads
  1409  		}
  1410  
  1411  		data.Threads = data.Threads[:0]
  1412  		for _, info := range threadInfo[start:end] {
  1413  			thread := db.PostByID(info[0])
  1414  			thread.Replies = info[1]
  1415  			posts := []*Post{thread}
  1416  			if overboard.Type == TypeImageboard {
  1417  				posts = append(posts, db.AllReplies(thread.ID, overboard.Replies, true)...)
  1418  			}
  1419  			data.Threads = append(data.Threads, posts)
  1420  		}
  1421  		data.Page = page
  1422  		data.execute(indexFile)
  1423  
  1424  		indexFile.Close()
  1425  		err = os.Rename(writePath, filePath)
  1426  		if err != nil {
  1427  			log.Fatal(err)
  1428  		}
  1429  	}
  1430  }
  1431  
  1432  // newPageTemplate returns a new collection of templates with read-only database access.
  1433  func (s *Server) newPageTemplate(db *database.DB) *template.Template {
  1434  	tpl, err := s.original.Clone()
  1435  	if err != nil {
  1436  		log.Fatal(err)
  1437  	}
  1438  	return tpl.Funcs(map[string]any{
  1439  		// Board.
  1440  		"BoardByID":       db.BoardByID,
  1441  		"BoardByDir":      db.BoardByDir,
  1442  		"UniqueUserPosts": db.UniqueUserPosts,
  1443  		"AllBoards":       db.AllBoards,
  1444  		// News.
  1445  		"NewsByID": db.NewsByID,
  1446  		"AllNews":  db.AllNews,
  1447  		// Page.
  1448  		"PageByID":   db.PageByID,
  1449  		"PageByPath": db.PageByPath,
  1450  		"AllPages":   db.AllPages,
  1451  		// Post.
  1452  		"AllThreads":       db.AllThreads,
  1453  		"AllPostsInThread": db.AllPostsInThread,
  1454  		"AllReplies":       db.AllReplies,
  1455  		"PendingPosts":     db.PendingPosts,
  1456  		"PostByID":         db.PostByID,
  1457  		"PostsByIP":        db.PostsByIP,
  1458  		"PostsByFileHash":  db.PostsByFileHash,
  1459  		"PostByField":      db.PostByField,
  1460  		"LastPostByIP":     db.LastPostByIP,
  1461  		"ReplyCount":       db.ReplyCount,
  1462  	})
  1463  }
  1464  
  1465  func (s *Server) writePage(db *database.DB, data *templateData, tpl *template.Template, p *Page, w io.Writer) error {
  1466  	err := p.Validate()
  1467  	if err != nil {
  1468  		return err
  1469  	}
  1470  
  1471  	dir := filepath.Dir(p.Path)
  1472  	if dir != "" {
  1473  		dirPath := filepath.Join(s.config.Root, dir)
  1474  		_, err := os.Stat(dirPath)
  1475  		if os.IsNotExist(err) {
  1476  			os.MkdirAll(dirPath, NewDirPermission)
  1477  		}
  1478  	}
  1479  
  1480  	if data == nil {
  1481  		data = s.newTemplateData()
  1482  		data.Boards = db.AllBoards()
  1483  		data.Template = "page"
  1484  	}
  1485  	if tpl == nil {
  1486  		tpl = s.newPageTemplate(db)
  1487  	}
  1488  
  1489  	data.tpl, err = tpl.Clone()
  1490  	if err != nil {
  1491  		log.Fatal(err)
  1492  	}
  1493  	data.tpl, err = data.tpl.New("line").Parse(p.Content)
  1494  	if err != nil {
  1495  		return err
  1496  	}
  1497  
  1498  	if strings.HasPrefix(p.Content, doctypePrefx) {
  1499  		data.Template = "line"
  1500  	} else {
  1501  		data.Template = "page"
  1502  	}
  1503  	return data.executeWithError(w)
  1504  }
  1505  
  1506  // writePages writes custom pages to disk.
  1507  func (s *Server) writePages(db *database.DB, pages []*Page) error {
  1508  	data := s.newTemplateData()
  1509  	data.Boards = db.AllBoards()
  1510  	data.Template = "page"
  1511  
  1512  	tpl := s.newPageTemplate(db)
  1513  	for _, p := range pages {
  1514  		writePath := filepath.Join(s.config.Root, p.Path+"_.html")
  1515  		filePath := filepath.Join(s.config.Root, p.Path+".html")
  1516  
  1517  		pageFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1518  		if err != nil {
  1519  			log.Fatal(err)
  1520  		}
  1521  
  1522  		err = s.writePage(db, data, tpl, p, pageFile)
  1523  		pageFile.Close()
  1524  		if err != nil {
  1525  			return err
  1526  		}
  1527  		err = os.Rename(writePath, filePath)
  1528  		if err != nil {
  1529  			return err
  1530  		}
  1531  	}
  1532  	return nil
  1533  }
  1534  
  1535  // rebuildBoard rebuilds a thread res page and board index pages.
  1536  func (s *Server) rebuildThread(db *database.DB, post *Post) {
  1537  	s.writeThread(db, post.Board, post.Thread())
  1538  	s.writeBoardIndexes(db, post.Board)
  1539  	if s.opt.Overboard != "" {
  1540  		s.writeOverboard(db)
  1541  	}
  1542  }
  1543  
  1544  // rebuildBoard rebuilds all pages in a board.
  1545  func (s *Server) rebuildBoard(db *database.DB, board *Board) {
  1546  	for _, info := range db.AllThreads(board, true) {
  1547  		s.writeThread(db, board, info[0])
  1548  	}
  1549  	s.writeBoardIndexes(db, board)
  1550  }
  1551  
  1552  // rebuildAll rebuilds all board, overboard, news and custom pages.
  1553  func (s *Server) rebuildAll(db *database.DB, verbose bool) {
  1554  	allPages := db.AllPages()
  1555  	if len(allPages) > 0 {
  1556  		if verbose {
  1557  			fmt.Println("Rebuilding pages...")
  1558  		}
  1559  		s.writePages(db, allPages)
  1560  	}
  1561  	published := len(db.AllNews(true))
  1562  	if published > 0 {
  1563  		if verbose {
  1564  			fmt.Println("Rebuilding news...")
  1565  		}
  1566  		s.rebuildNews(db)
  1567  	}
  1568  	if s.opt.Overboard != "" {
  1569  		if verbose {
  1570  			fmt.Println("Rebuilding overboard...")
  1571  		}
  1572  		s.writeOverboard(db)
  1573  	}
  1574  	for _, b := range db.AllBoards() {
  1575  		if verbose {
  1576  			fmt.Printf("Rebuilding %s...\n", b.Path())
  1577  		}
  1578  		s.rebuildBoard(db, b)
  1579  	}
  1580  	s.writeSiteIndex(db)
  1581  	s.writeVisitorGuide(db)
  1582  }
  1583  
  1584  // writeNewsItem writes a news entry page to disk.
  1585  func (s *Server) writeNewsItem(db *database.DB, n *News) {
  1586  	if n.ID <= 0 {
  1587  		return
  1588  	}
  1589  
  1590  	data := s.newTemplateData()
  1591  	data.Boards = db.AllBoards()
  1592  	data.Template = "news"
  1593  	data.AllNews = []*News{n}
  1594  	data.Pages = 1
  1595  	data.Extra = "view"
  1596  
  1597  	writePath := filepath.Join(s.config.Root, fmt.Sprintf("_news-%d.html", n.ID))
  1598  	filePath := filepath.Join(s.config.Root, fmt.Sprintf("news-%d.html", n.ID))
  1599  
  1600  	itemFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1601  	if err != nil {
  1602  		log.Fatal(err)
  1603  	}
  1604  	data.execute(itemFile)
  1605  	itemFile.Close()
  1606  	err = os.Rename(writePath, filePath)
  1607  	if err != nil {
  1608  		log.Fatal(err)
  1609  	}
  1610  }
  1611  
  1612  // writeNewsIndexes writes news index pages to disk.
  1613  func (s *Server) writeNewsIndexes(db *database.DB) {
  1614  	allNews := db.AllNews(true)
  1615  	data := s.newTemplateData()
  1616  	data.Boards = db.AllBoards()
  1617  	data.Template = "news"
  1618  
  1619  	const newsCount = 10
  1620  	data.Pages = pageCount(len(allNews), newsCount)
  1621  	for page := 0; page < data.Pages; page++ {
  1622  		fileName := "news.html"
  1623  		if s.opt.News == NewsWriteToIndex {
  1624  			fileName = "index.html"
  1625  		}
  1626  		if page > 0 {
  1627  			fileName = fmt.Sprintf("news-p%d.html", page)
  1628  		}
  1629  
  1630  		writePath := filepath.Join(s.config.Root, "_"+fileName)
  1631  		filePath := filepath.Join(s.config.Root, fileName)
  1632  
  1633  		indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1634  		if err != nil {
  1635  			log.Fatal(err)
  1636  		}
  1637  
  1638  		start := page * newsCount
  1639  		end := len(allNews)
  1640  		if newsCount != 0 && end > start+newsCount {
  1641  			end = start + newsCount
  1642  		}
  1643  
  1644  		data.AllNews = allNews[start:end]
  1645  		data.Page = page
  1646  		data.execute(indexFile)
  1647  
  1648  		indexFile.Close()
  1649  		err = os.Rename(writePath, filePath)
  1650  		if err != nil {
  1651  			log.Fatal(err)
  1652  		}
  1653  	}
  1654  }
  1655  
  1656  // rebuildNewsItem rebuilds a news entry.
  1657  func (s *Server) rebuildNewsItem(db *database.DB, n *News) {
  1658  	s.writeNewsItem(db, n)
  1659  	s.writeNewsIndexes(db)
  1660  }
  1661  
  1662  // rebuildNews rebuilds all news entries.
  1663  func (s *Server) rebuildNews(db *database.DB) {
  1664  	for _, n := range db.AllNews(true) {
  1665  		s.writeNewsItem(db, n)
  1666  	}
  1667  	s.writeNewsIndexes(db)
  1668  }
  1669  
  1670  // writeVisitorGuide writes the visitor guide to disk.
  1671  func (s *Server) writeVisitorGuide(db *database.DB) {
  1672  	data := s.newTemplateData()
  1673  	data.Template = "guide"
  1674  	data.Boards = db.AllBoards()
  1675  
  1676  	writePath := filepath.Join(s.config.Root, "_guide.html")
  1677  	filePath := filepath.Join(s.config.Root, "guide.html")
  1678  
  1679  	file, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1680  	if err != nil {
  1681  		log.Fatal(err)
  1682  	}
  1683  	data.execute(file)
  1684  	file.Close()
  1685  	err = os.Rename(writePath, filePath)
  1686  	if err != nil {
  1687  		log.Fatal(err)
  1688  	}
  1689  }
  1690  
  1691  // writeSiteIndex writes the site index page to disk.
  1692  func (s *Server) writeSiteIndex(db *database.DB) {
  1693  	if !s.opt.SiteIndex || s.opt.News == NewsWriteToIndex || s.opt.Overboard == "/" || db.BoardByDir("") != nil {
  1694  		return
  1695  	}
  1696  	allBoards := db.AllBoards()
  1697  	var keep []*Board
  1698  	for _, board := range allBoards {
  1699  		if board.Hide == HideIndex || board.Hide == HideEverywhere {
  1700  			continue
  1701  		}
  1702  		keep = append(keep, board)
  1703  	}
  1704  	allBoards = keep
  1705  	if len(allBoards) < 2 {
  1706  		return
  1707  	}
  1708  	data := s.newTemplateData()
  1709  	data.Template = "index"
  1710  
  1711  	data.Boards = allBoards
  1712  
  1713  	if s.opt.News != NewsDisable {
  1714  		allNews := db.AllNews(true)
  1715  		var latest *News
  1716  		if len(allNews) > 0 {
  1717  			latest = allNews[0]
  1718  		}
  1719  		data.News = latest
  1720  	}
  1721  
  1722  	s.refreshRecentPosts(db)
  1723  
  1724  	writePath := filepath.Join(s.config.Root, "_index.html")
  1725  	filePath := filepath.Join(s.config.Root, "index.html")
  1726  
  1727  	indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1728  	if err != nil {
  1729  		log.Fatal(err)
  1730  	}
  1731  	data.execute(indexFile)
  1732  	indexFile.Close()
  1733  	err = os.Rename(writePath, filePath)
  1734  	if err != nil {
  1735  		log.Fatal(err)
  1736  	}
  1737  }
  1738  
  1739  // removeInvalidBoardOptions removes invalid board options from the database.
  1740  func (s *Server) removeInvalidBoardOptions(db *database.DB) {
  1741  	for _, b := range db.AllBoards() {
  1742  		var keep []string
  1743  		var modified bool
  1744  		for _, boardEmbed := range b.Embeds {
  1745  			var found bool
  1746  			for _, serverEmbed := range s.opt.Embeds {
  1747  				if serverEmbed[0] == boardEmbed {
  1748  					found = true
  1749  					break
  1750  				}
  1751  			}
  1752  			if !found {
  1753  				modified = true
  1754  				continue
  1755  			}
  1756  			keep = append(keep, boardEmbed)
  1757  		}
  1758  		if modified {
  1759  			b.Embeds = keep
  1760  			db.UpdateBoard(b)
  1761  		}
  1762  	}
  1763  }
  1764  
  1765  // reloadBans refreshes the range ban regular expression cache.
  1766  func (s *Server) reloadBans(db *database.DB) {
  1767  	var rangeBans = make(map[*Ban]*regexp.Regexp)
  1768  	bans := db.AllBans(true)
  1769  	for _, ban := range bans {
  1770  		pattern, err := regexp.Compile(ban.IP[2:])
  1771  		if err != nil {
  1772  			log.Printf("warning: failed to compile IP range ban `%s` as regular expression: %s", ban.IP[2:], err)
  1773  			return
  1774  		}
  1775  		rangeBans[ban] = pattern
  1776  	}
  1777  	s.rangeBans = rangeBans
  1778  }
  1779  
  1780  // serveManage serves management panel web requests.
  1781  func (s *Server) serveManage(db *database.DB, w http.ResponseWriter, r *http.Request) {
  1782  	data := s.buildData(db, w, r)
  1783  	if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
  1784  		return
  1785  	}
  1786  	var skipExecute bool
  1787  
  1788  	if len(data.Info) != 0 {
  1789  		data.Template = "manage_error"
  1790  		data.execute(w)
  1791  		return
  1792  	}
  1793  
  1794  	if data.Account != nil {
  1795  		db.UpdateAccountLastActive(data.Account.ID)
  1796  	}
  1797  
  1798  	data.Template = "manage_login"
  1799  
  1800  	if data.Account == nil {
  1801  		data.execute(w)
  1802  		return
  1803  	} else if s.config.ImportMode {
  1804  		if data.Account.Role != RoleSuperAdmin {
  1805  			data.ManageError("Sriracha is running in import mode. Only super-administrators may log in.")
  1806  			data.execute(w)
  1807  			return
  1808  		} else if !strings.HasPrefix(r.URL.Path, "/sriracha/import/") && !strings.HasPrefix(r.URL.Path, "/sriracha/board/") {
  1809  			data.Redirect(w, r, "/sriracha/import/")
  1810  			return
  1811  		}
  1812  		data.Info = Get(nil, data.Account, "Import mode enabled. Visitors are forbidden from posting.")
  1813  	}
  1814  
  1815  	switch {
  1816  	case strings.HasPrefix(r.URL.Path, "/sriracha/preference"):
  1817  		s.servePreference(data, db, w, r)
  1818  	case strings.HasPrefix(r.URL.Path, "/sriracha/account"):
  1819  		s.serveAccount(data, db, w, r)
  1820  	case strings.HasPrefix(r.URL.Path, "/sriracha/banner"):
  1821  		s.serveBanner(data, db, w, r)
  1822  	case strings.HasPrefix(r.URL.Path, "/sriracha/ban"):
  1823  		s.serveBan(data, db, w, r)
  1824  	case strings.HasPrefix(r.URL.Path, "/sriracha/board"):
  1825  		skipExecute = s.serveBoard(data, db, w, r)
  1826  	case strings.HasPrefix(r.URL.Path, "/sriracha/category"):
  1827  		s.serveCategory(data, db, w, r)
  1828  	case strings.HasPrefix(r.URL.Path, "/sriracha/import"):
  1829  		s.serveImport(data, db, w, r)
  1830  	case strings.HasPrefix(r.URL.Path, "/sriracha/keyword"):
  1831  		s.serveKeyword(data, db, w, r)
  1832  	case strings.HasPrefix(r.URL.Path, "/sriracha/log"):
  1833  		s.serveLog(data, db, w, r)
  1834  	case strings.HasPrefix(r.URL.Path, "/sriracha/mod"):
  1835  		s.serveMod(data, db, w, r)
  1836  	case strings.HasPrefix(r.URL.Path, "/sriracha/news"):
  1837  		s.serveNews(data, db, w, r)
  1838  	case strings.HasPrefix(r.URL.Path, "/sriracha/page"):
  1839  		s.servePage(data, db, w, r)
  1840  	case strings.HasPrefix(r.URL.Path, "/sriracha/plugin"):
  1841  		s.servePlugin(data, db, w, r)
  1842  	case strings.HasPrefix(r.URL.Path, "/sriracha/setting"):
  1843  		s.serveSetting(data, db, w, r)
  1844  	default:
  1845  		s.serveStatus(data, db, w, r)
  1846  	}
  1847  
  1848  	if skipExecute {
  1849  		return
  1850  	}
  1851  	data.execute(w)
  1852  }
  1853  
  1854  // serve serves web requests.
  1855  func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
  1856  	// Set Server header.
  1857  	w.Header().Set("Server", "Sriracha GNU LGPL")
  1858  
  1859  	// Check Content-Length header.
  1860  	if r.ContentLength > s.httpMaxRequestSize {
  1861  		http.Error(w, fmt.Sprintf("Exceeded maximum request size (%s)", FormatFileSize(s.httpMaxRequestSize)), http.StatusBadRequest)
  1862  		return
  1863  	}
  1864  
  1865  	// Limit request size.
  1866  	r.Body = http.MaxBytesReader(w, r.Body, s.httpMaxRequestSize)
  1867  
  1868  	// Parse form.
  1869  	if r.Method == http.MethodPost {
  1870  		const maxMemory = 32 << 20 // 32 megabytes.
  1871  		err := r.ParseMultipartForm(maxMemory)
  1872  		if r.MultipartForm != nil {
  1873  			defer r.MultipartForm.RemoveAll()
  1874  		}
  1875  		if err != nil && err != http.ErrNotMultipart {
  1876  			http.Error(w, err.Error(), http.StatusBadRequest)
  1877  			return
  1878  		}
  1879  
  1880  		var modified bool
  1881  		f := make(url.Values)
  1882  		for key, values := range r.Form {
  1883  			f[key] = make([]string, len(values))
  1884  			for i := range values {
  1885  				modified = true
  1886  				f[key][i] = strings.ReplaceAll(values[i], "\r", "")
  1887  			}
  1888  		}
  1889  		if modified {
  1890  			r.Form = f
  1891  		}
  1892  	}
  1893  
  1894  	// Parse action from path.
  1895  	var action string
  1896  	switch r.URL.Path {
  1897  	case "/sriracha/", "/sriracha":
  1898  		action = r.FormValue("action")
  1899  		if action == "" {
  1900  			values := r.URL.Query()
  1901  			action = values.Get("action")
  1902  		}
  1903  	case "/sriracha/captcha":
  1904  		action = "captcha"
  1905  	}
  1906  
  1907  	s.lock.Lock()
  1908  	defer s.lock.Unlock()
  1909  
  1910  	db := s.begin()
  1911  	defer db.Commit()
  1912  
  1913  	db.DeleteExpiredSubscriptions()
  1914  
  1915  	if db.DeleteExpiredBans() > 0 {
  1916  		s.reloadBans(db)
  1917  	}
  1918  
  1919  	// Check IP range ban.
  1920  	ip := s.requestIP(r)
  1921  	for ban, pattern := range s.rangeBans {
  1922  		if pattern.MatchString(ip) {
  1923  			data := s.buildData(db, w, r)
  1924  			data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
  1925  			data.execute(w)
  1926  			return
  1927  		}
  1928  	}
  1929  
  1930  	// Check static IP ban.
  1931  	ban := db.BanByIP(s.hashIP(r))
  1932  	if ban != nil {
  1933  		data := s.buildData(db, w, r)
  1934  		data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
  1935  		data.execute(w)
  1936  		return
  1937  	} else if strings.HasPrefix(r.URL.Path, "/sriracha/post/") {
  1938  		postID := PathInt(r, "/sriracha/post/")
  1939  		post := db.PostByID(postID)
  1940  		data := s.buildData(db, w, r)
  1941  		if post == nil {
  1942  			data.BoardError(w, "Invalid or deleted post.")
  1943  		} else {
  1944  			data.Redirect(w, r, fmt.Sprintf("%sres/%d.html#%d", post.Board.Path(), post.Thread(), post.ID))
  1945  		}
  1946  		return
  1947  	}
  1948  
  1949  	if strings.HasPrefix(r.URL.Path, "/sriracha/subscribe") {
  1950  		action = "subscribe"
  1951  	}
  1952  
  1953  	if s.config.ImportMode && action != "" {
  1954  		data := s.buildData(db, w, r)
  1955  		data.BoardError(w, "All boards are locked because Sriracha is running in import mode. Please try again later.")
  1956  	} else {
  1957  		switch action {
  1958  		case "post":
  1959  			s.servePost(db, w, r)
  1960  		case "report":
  1961  			s.serveReport(db, w, r)
  1962  		case "delete":
  1963  			s.serveDelete(db, w, r)
  1964  		case "captcha":
  1965  			s.serveCAPTCHA(db, w, r)
  1966  		case "subscribe":
  1967  			s.serveSubscribe(db, w, r)
  1968  		default:
  1969  			s.serveManage(db, w, r)
  1970  		}
  1971  	}
  1972  }
  1973  
  1974  var cachePattern = regexp.MustCompile(`^/static/.*$`)
  1975  
  1976  func withCacheHeader(fs http.Handler) http.HandlerFunc {
  1977  	return func(w http.ResponseWriter, r *http.Request) {
  1978  		if cachePattern.MatchString(r.URL.Path) {
  1979  			// Cache static files.
  1980  			w.Header().Set("Cache-Control", "public, max-age=1209600, immutable")
  1981  		} else {
  1982  			// Revalidate HTML files.
  1983  			w.Header().Set("Cache-Control", "public, no-cache")
  1984  		}
  1985  		fs.ServeHTTP(w, r)
  1986  	}
  1987  }
  1988  
  1989  // listen listens for HTTP connections and sends the error returned by the HTTP
  1990  // server via the provided channel.
  1991  func (s *Server) listen(httpErrors chan error) {
  1992  	info, err := os.Stat("static/css/futaba.css")
  1993  	if err != nil || info.IsDir() {
  1994  		httpErrors <- fmt.Errorf("failed to locate static directory, unable to serve CSS and JS files: run sriracha from the directory that contains static as a subdirectory")
  1995  		return
  1996  	}
  1997  
  1998  	mux := http.NewServeMux()
  1999  	mux.Handle("/static/", withCacheHeader(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))))
  2000  	mux.HandleFunc("/sriracha/", s.serve)
  2001  	mux.Handle("/", withCacheHeader(http.FileServer(http.Dir(s.config.Root))))
  2002  
  2003  	if s.config.HTTPS != "" {
  2004  		cert, err := tls.LoadX509KeyPair(s.config.HTTPSCert, s.config.HTTPSKey)
  2005  		if err != nil {
  2006  			httpErrors <- fmt.Errorf("failed to load HTTPS certificate %s and key %s: %s", s.config.HTTPSCert, s.config.HTTPSKey, err)
  2007  			return
  2008  		}
  2009  		s.httpsCert = &cert
  2010  
  2011  		tlsConfig := &tls.Config{
  2012  			GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
  2013  				return s.httpsCert, nil
  2014  			},
  2015  			InsecureSkipVerify: s.config.InsecureSkipVerify,
  2016  		}
  2017  
  2018  		p := &http.Protocols{}
  2019  		p.SetHTTP1(!s.config.RejectHTTP1)
  2020  		p.SetHTTP2(true)
  2021  		p.SetUnencryptedHTTP2(false)
  2022  
  2023  		s.httpsServer = &http.Server{
  2024  			Addr:              s.config.HTTPS,
  2025  			Handler:           mux,
  2026  			TLSConfig:         tlsConfig,
  2027  			ReadHeaderTimeout: 1 * time.Minute,
  2028  			IdleTimeout:       1 * time.Minute,
  2029  			Protocols:         p,
  2030  			HTTP2: &http.HTTP2Config{
  2031  				WriteByteTimeout: 1 * time.Minute,
  2032  			},
  2033  		}
  2034  
  2035  		go func() {
  2036  			httpErrors <- s.httpsServer.ListenAndServeTLS("", "")
  2037  		}()
  2038  	}
  2039  
  2040  	http2Server := &http2.Server{
  2041  		IdleTimeout:      1 * time.Minute,
  2042  		WriteByteTimeout: 1 * time.Minute,
  2043  	}
  2044  
  2045  	p := &http.Protocols{}
  2046  	p.SetHTTP1(!s.config.RejectHTTP1)
  2047  	p.SetHTTP2(true)
  2048  	p.SetUnencryptedHTTP2(true)
  2049  
  2050  	s.httpServer = &http.Server{
  2051  		Addr:              s.config.HTTP,
  2052  		Handler:           h2c.NewHandler(mux, http2Server),
  2053  		ReadHeaderTimeout: 1 * time.Minute,
  2054  		IdleTimeout:       1 * time.Minute,
  2055  		Protocols:         p,
  2056  		HTTP2: &http.HTTP2Config{
  2057  			WriteByteTimeout: 1 * time.Minute,
  2058  		},
  2059  	}
  2060  
  2061  	httpErrors <- s.httpServer.ListenAndServe()
  2062  }
  2063  
  2064  // handleRebuild handles requests to rebuild threads.
  2065  func (s *Server) handleRebuild() {
  2066  	defer s.rebuildWaitGroup.Done()
  2067  
  2068  	minWait := 1 * time.Second
  2069  	maxWait := 10 * time.Second
  2070  
  2071  	lastBuild := time.Now()
  2072  
  2073  	var info *rebuildInfo
  2074  	var pending []*rebuildInfo
  2075  	var boards []*Board
  2076  	var threads []int
  2077  	var shutdown bool
  2078  	var t *time.Timer
  2079  	for {
  2080  		// Process queue.
  2081  		info = <-s.rebuildQueue
  2082  		if info == nil {
  2083  			return // Shut down.
  2084  		}
  2085  		pending = append(pending, info)
  2086  		if time.Since(lastBuild) < maxWait {
  2087  			for {
  2088  				// Drain queue until minimum wait time has passed.
  2089  				t = time.NewTimer(minWait)
  2090  				var found bool
  2091  			DRAINQUEUE:
  2092  				for {
  2093  					select {
  2094  					case info = <-s.rebuildQueue:
  2095  						if info == nil {
  2096  							shutdown = true
  2097  						} else {
  2098  							pending = append(pending, info)
  2099  							found = true
  2100  						}
  2101  					case <-t.C:
  2102  						break DRAINQUEUE
  2103  					}
  2104  				}
  2105  				if !found {
  2106  					break
  2107  				}
  2108  				// Check if maximum wait time has passed.
  2109  				if time.Since(lastBuild) >= maxWait {
  2110  					break
  2111  				}
  2112  			}
  2113  		}
  2114  		if shutdown && len(pending) == 0 {
  2115  			return
  2116  		}
  2117  
  2118  		// Flush queue.
  2119  		db := s.begin()
  2120  		for _, info := range pending {
  2121  			thread := info.post.Thread()
  2122  			if !slices.Contains(threads, thread) {
  2123  				s.writeThread(db, info.post.Board, thread)
  2124  				threads = append(threads, thread)
  2125  			}
  2126  			if !slices.Contains(boards, info.post.Board) {
  2127  				s.writeBoardIndexes(db, info.post.Board)
  2128  				boards = append(boards, info.post.Board)
  2129  			}
  2130  		}
  2131  		if s.opt.Overboard != "" {
  2132  			s.writeOverboard(db)
  2133  		}
  2134  		s.writeSiteIndex(db)
  2135  		if s.opt.Notifications {
  2136  			for _, info := range pending {
  2137  				s.queueNotifications(db, info.post)
  2138  			}
  2139  		}
  2140  		db.Commit()
  2141  
  2142  		for _, info := range pending {
  2143  			info.wg.Done()
  2144  		}
  2145  
  2146  		pending = pending[:0]
  2147  		boards = boards[:0]
  2148  		threads = threads[:0]
  2149  
  2150  		lastBuild = time.Now()
  2151  
  2152  		if shutdown {
  2153  			return
  2154  		}
  2155  	}
  2156  }
  2157  
  2158  func (s *Server) _handleSignal(signals chan os.Signal) {
  2159  	for {
  2160  		// Wait until SIGHUP, SIGINT or SIGTERM is received.
  2161  		sig := <-signals
  2162  
  2163  		// Rebuild static files when SIGHUP is received.
  2164  		if sig == unix.SIGHUP {
  2165  			// Rebuild staic files.
  2166  			db := s.begin()
  2167  			s.rebuildAll(db, true)
  2168  			db.Commit()
  2169  
  2170  			// Reload HTTPS certificate files.
  2171  			if s.config.HTTPS != "" {
  2172  				cert, err := tls.LoadX509KeyPair(s.config.HTTPSCert, s.config.HTTPSKey)
  2173  				if err != nil {
  2174  					log.Fatalf("failed to load HTTPS certificate %s and key %s: %s", s.config.HTTPSCert, s.config.HTTPSKey, err)
  2175  				}
  2176  				s.httpsCert = &cert
  2177  				fmt.Printf("HTTPS certificate files reloaded.\n")
  2178  			}
  2179  
  2180  			var extra string
  2181  			if s.config.HTTPS != "" {
  2182  				extra = " and https://" + s.config.HTTPS
  2183  			}
  2184  			fmt.Printf("Serving http://%s%s\n", s.config.HTTP, extra)
  2185  			continue
  2186  		}
  2187  
  2188  		// Shut down server when SIGINT or SIGTERM is received.
  2189  		s.Stop()
  2190  		return
  2191  	}
  2192  }
  2193  
  2194  // startSignalHandler starts the signal handler which rebuilds static files on
  2195  // SIGHUP and shuts down the server on SIGINT or SIGTERM.
  2196  func (s *Server) startSignalHandler() {
  2197  	signals := make(chan os.Signal, 1)
  2198  	signal.Notify(signals, unix.SIGHUP, unix.SIGINT, unix.SIGTERM)
  2199  	go s._handleSignal(signals)
  2200  }
  2201  
  2202  // Run initializes the server and starts listening for connections.
  2203  func (s *Server) Run() error {
  2204  	s.parseBuildInfo()
  2205  
  2206  	// Parse flags and arguments.
  2207  	printInfo := func() {
  2208  		fmt.Fprintf(os.Stderr, "\nSriracha imageboard and forum server\n  https://codeberg.org/tslocum/sriracha\nGNU LESSER GENERAL PUBLIC LICENSE\n  https://codeberg.org/tslocum/sriracha/src/branch/main/LICENSE\n")
  2209  	}
  2210  	flag.Usage = func() {
  2211  		fmt.Fprintf(os.Stderr, "Usage:\n  sriracha [OPTION...] [PLUGIN...]\n\nOptions:\n")
  2212  		flag.PrintDefaults()
  2213  		printInfo()
  2214  	}
  2215  	var (
  2216  		configFile   string
  2217  		exportPath   string
  2218  		importPath   string
  2219  		devMode      bool
  2220  		rebuild      bool
  2221  		printVersion bool
  2222  	)
  2223  	flag.StringVar(&configFile, "config", "", "path to configuration file (default: ~/.config/sriracha/config.yml)")
  2224  	flag.StringVar(&exportPath, "export", "", "export posts to zip file at specified path")
  2225  	flag.StringVar(&importPath, "import", "", "import posts from zip file or sqlite database file at specified path")
  2226  	flag.BoolVar(&devMode, "dev", false, "run in development mode (watch official and custom template files for changes)")
  2227  	flag.BoolVar(&rebuild, "rebuild", false, "rebuild static files on startup")
  2228  	flag.BoolVar(&printVersion, "version", false, "print version information and exit")
  2229  	flag.Parse()
  2230  
  2231  	// Print version information and exit.
  2232  	if printVersion {
  2233  		fmt.Fprintf(os.Stderr, "Sriracha version %s\n", SrirachaVersion)
  2234  		printInfo()
  2235  		return nil
  2236  	}
  2237  
  2238  	// Start rebuild queue handler.
  2239  	s.rebuildWaitGroup.Add(1)
  2240  	go s.handleRebuild()
  2241  
  2242  	// Start SIGINT and SIGTERM signal handler.
  2243  	s.startSignalHandler()
  2244  
  2245  	// Set default server configuration file path.
  2246  	if configFile == "" {
  2247  		homeDir, err := os.UserHomeDir()
  2248  		if err == nil {
  2249  			configFile = path.Join(homeDir, ".config", "sriracha", "config.yml")
  2250  		}
  2251  	}
  2252  
  2253  	// Parse server YAML configuration file.
  2254  	err := s.parseConfig(configFile)
  2255  	if err != nil {
  2256  		return fmt.Errorf("failed to parse configuration %s: %s", configFile, err)
  2257  	}
  2258  	s.config.StartTime = time.Now()
  2259  
  2260  	// Set default gettext domain.
  2261  	gotext.SetDomain("sriracha")
  2262  
  2263  	// Parse locale files.
  2264  	err = s.parseLocales()
  2265  	if err != nil {
  2266  		log.Fatalf("failed to parse locale files: %s", err)
  2267  	}
  2268  
  2269  	// Locate official templates and validate custom template configuration.
  2270  	var officialDir string
  2271  	if devMode {
  2272  		s.opt.DevMode = true
  2273  
  2274  		officialDir = s.officialTemplateDir()
  2275  		if officialDir == "" {
  2276  			return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
  2277  		}
  2278  
  2279  		err = s.validateTemplateConfig(officialDir)
  2280  		if err != nil {
  2281  			return fmt.Errorf("invalid custom template directory: %s", err)
  2282  		}
  2283  	}
  2284  
  2285  	// Verify mail server configuration.
  2286  	s.opt.Notifications = s.config.MailAddress != ""
  2287  	if s.opt.Notifications && !devMode {
  2288  		fmt.Println("Verifying mail server configuration...")
  2289  		client, err := s.connectToMailServer()
  2290  		if err != nil {
  2291  			log.Fatalf("failed to verify mail server configuration: %s", err)
  2292  		}
  2293  		client.Close()
  2294  	}
  2295  
  2296  	// Initialize database connection pool, which contains one connection.
  2297  	s.dbPool, err = database.Connect(s.config)
  2298  	if err != nil {
  2299  		return fmt.Errorf("failed to connect to database: %s", err)
  2300  	}
  2301  
  2302  	// Load server configuration and set default values.
  2303  	err = s.loadServerConfig()
  2304  	if err != nil {
  2305  		return fmt.Errorf("failed to set default server configuration: %s", err)
  2306  	}
  2307  
  2308  	// Load plugin configuration.
  2309  	err = s.loadPluginConfig()
  2310  	if err != nil {
  2311  		return fmt.Errorf("failed to load plugin configuration: %s", err)
  2312  	}
  2313  
  2314  	// Load plugins.
  2315  	err = s.loadPlugins()
  2316  	if err != nil {
  2317  		return fmt.Errorf("failed to load plugins: %s", err)
  2318  	}
  2319  
  2320  	// Set default plugin configuration.
  2321  	err = s.setDefaultPluginConfig()
  2322  	if err != nil {
  2323  		return fmt.Errorf("failed to set default plugin configuration: %s", err)
  2324  	}
  2325  
  2326  	// Parse template files.
  2327  	err = s.parseTemplates(officialDir, s.config.Template)
  2328  	if err != nil {
  2329  		return fmt.Errorf("failed to parse template files: %s", err)
  2330  	}
  2331  
  2332  	// Export posts.
  2333  	if exportPath != "" {
  2334  		db := s.begin()
  2335  		defer db.Commit()
  2336  
  2337  		if !strings.HasSuffix(strings.ToLower(exportPath), ".zip") {
  2338  			exportPath += ".sriracha.zip"
  2339  		}
  2340  
  2341  		err := s.exportPosts(db, exportPath)
  2342  		if err != nil {
  2343  			return fmt.Errorf("failed to export posts: %s", err)
  2344  		}
  2345  		return nil
  2346  	}
  2347  
  2348  	// Import posts.
  2349  	if importPath != "" {
  2350  		s.config.ImportMode = true
  2351  		err := s.importDatabase(importPath)
  2352  		if err != nil {
  2353  			return fmt.Errorf("failed to import posts: %s", err)
  2354  		}
  2355  	}
  2356  
  2357  	// Verify root directory is writable.
  2358  	if unix.Access(s.config.Root, unix.W_OK) != nil {
  2359  		return fmt.Errorf("configured root directory %s is not writable", s.config.Root)
  2360  	}
  2361  
  2362  	// Create captcha directory.
  2363  	captchaDir := filepath.Join(s.config.Root, "captcha")
  2364  	_, err = os.Stat(captchaDir)
  2365  	if os.IsNotExist(err) {
  2366  		err := os.Mkdir(captchaDir, NewDirPermission)
  2367  		if err != nil {
  2368  			log.Fatalf("failed to create captcha directory: %s", err)
  2369  		}
  2370  	}
  2371  
  2372  	// Create banner directory.
  2373  	bannerDir := filepath.Join(s.config.Root, "banner")
  2374  	_, err = os.Stat(bannerDir)
  2375  	if os.IsNotExist(err) {
  2376  		err := os.Mkdir(bannerDir, NewDirPermission)
  2377  		if err != nil {
  2378  			log.Fatalf("failed to create banner directory: %s", err)
  2379  		}
  2380  	}
  2381  
  2382  	// Write default site index file.
  2383  	siteIndexFile := filepath.Join(s.config.Root, "index.html")
  2384  	_, err = os.Stat(siteIndexFile)
  2385  	if os.IsNotExist(err) {
  2386  		err = os.WriteFile(siteIndexFile, siteIndexHTML, NewFilePermission)
  2387  		if err != nil {
  2388  			log.Fatalf("failed to write site index at %s: %s", siteIndexFile, err)
  2389  		}
  2390  	}
  2391  
  2392  	// Lock server until initialization is complete.
  2393  	s.lock.Lock()
  2394  
  2395  	db := s.begin()
  2396  	s.refreshMaxRequestSize(db)
  2397  
  2398  	// Start listening for HTTP connections.
  2399  	httpErrors := make(chan error)
  2400  	go s.listen(httpErrors)
  2401  
  2402  	// Rebuild everything on startup when explicitly requested and after upgrading.
  2403  	s.refreshBannerCache(db)
  2404  	s.refreshRulesCache(db)
  2405  	s.refreshCategoryCache(db)
  2406  	s.refreshKeywordCache(db)
  2407  	sv := db.GetString("sv") // Sriracha version.
  2408  	if sv != SrirachaVersion {
  2409  		if sv != "" {
  2410  			fmt.Printf("Upgraded from Sriracha version %s to %s, rebuilding...\n", sv, SrirachaVersion)
  2411  			rebuild = true
  2412  		}
  2413  		db.SaveString("sv", SrirachaVersion)
  2414  	}
  2415  	if rebuild {
  2416  		s.rebuildAll(db, true)
  2417  	}
  2418  	db.Commit()
  2419  
  2420  	// Watch template directories.
  2421  	if devMode {
  2422  		dir := "directory"
  2423  		if s.config.Template != "" {
  2424  			dir = "directories"
  2425  		}
  2426  		fmt.Printf("Development mode enabled. Monitoring template %s...\n", dir)
  2427  		err = s.watchTemplates(officialDir)
  2428  		if err != nil {
  2429  			s.lock.Unlock()
  2430  			return fmt.Errorf("failed to watch templates for changes: %s", err)
  2431  		}
  2432  	}
  2433  
  2434  	// Start notification queue handler.
  2435  	if s.config.MailAddress != "" {
  2436  		s.notificationsWaitGroup.Add(1)
  2437  		go s.handleNotifications()
  2438  	}
  2439  
  2440  	if s.config.ImportMode {
  2441  		fmt.Println("Import mode enabled. Visitors are forbidden from posting.")
  2442  	}
  2443  
  2444  	// Initialization complete. Unlock server.
  2445  	var extra string
  2446  	if s.config.HTTPS != "" {
  2447  		extra = " and https://" + s.config.HTTPS
  2448  	}
  2449  	fmt.Printf("Serving http://%s%s\n", s.config.HTTP, extra)
  2450  	s.lock.Unlock()
  2451  
  2452  	// Wait until the HTTP server returns an error.
  2453  	err = <-httpErrors
  2454  
  2455  	// Shut down gracefully.
  2456  	if err == http.ErrServerClosed {
  2457  		// Wait until all web requests have been processed.
  2458  		s.rebuildWaitGroup.Wait()
  2459  		// Wait until all notifications have been sent.
  2460  		s.notificationsWaitGroup.Wait()
  2461  		return nil
  2462  	}
  2463  	return err
  2464  }
  2465  
  2466  // newHash returns a new hash digest.
  2467  func (s *Server) newHash() hash.Hash {
  2468  	if s.opt.Algorithm == AlgorithmSHA3 {
  2469  		return sha3.New384()
  2470  	}
  2471  	return sha512.New384()
  2472  }
  2473  
  2474  // hashBytes returns the hash of the provided bytes and optional salt.
  2475  func (s *Server) hashBytes(buf []byte, salt string) string {
  2476  	hash := s.newHash()
  2477  	hash.Write(buf)
  2478  	if salt != "" {
  2479  		hash.Write([]byte(salt))
  2480  	}
  2481  	var sum [HashSize]byte
  2482  	hash.Sum(sum[:0])
  2483  	return base64.URLEncoding.EncodeToString(sum[:])
  2484  }
  2485  
  2486  // hashData returns the salted hash of the provided data.
  2487  func (s *Server) hashData(data string) string {
  2488  	return s.hashBytes([]byte(data), s.config.SaltData)
  2489  }
  2490  
  2491  // md5Sum returns the MD5 sum of the provided data.
  2492  func md5Sum(data string) string {
  2493  	return fmt.Sprintf("%x", md5.Sum([]byte(data)))
  2494  }
  2495  
  2496  // parseHostname returns the hostname portion of an address.
  2497  func parseHostname(address string) string {
  2498  	if address == "" {
  2499  		return ""
  2500  	}
  2501  	hostname, port, err := net.SplitHostPort(address)
  2502  	if err != nil || port == "" {
  2503  		return address
  2504  	}
  2505  	return hostname
  2506  }
  2507  
  2508  // requestIP returns the remote IP address of a request.
  2509  func (s *Server) requestIP(r *http.Request) string {
  2510  	var address string
  2511  	if s.config.Header != "" {
  2512  		values := r.Header[s.config.Header]
  2513  		if len(values) > 0 {
  2514  			address = values[0]
  2515  		}
  2516  	} else {
  2517  		address = r.RemoteAddr
  2518  	}
  2519  	if address == "" {
  2520  		log.Fatal("Error: No client IP address specified in HTTP request. Are you sure the header server option is correct? See MANUAL.md for more info.")
  2521  	}
  2522  	return parseHostname(address)
  2523  }
  2524  
  2525  func (s *Server) _hashIP(address string) string {
  2526  	if address == "" {
  2527  		return ""
  2528  	}
  2529  	return s.hashData(parseHostname(address))
  2530  }
  2531  
  2532  // hashIP returns the salted hash of a request's IP address.
  2533  func (s *Server) hashIP(r *http.Request) string {
  2534  	return s._hashIP(s.requestIP(r))
  2535  }
  2536  
  2537  // imageDimensions returns the width and height of a JPG, PNG or GIF image.
  2538  func (s *Server) imageDimensions(reader io.Reader) (int, int) {
  2539  	imgConfig, _, err := image.DecodeConfig(reader)
  2540  	if err != nil {
  2541  		return 0, 0
  2542  	}
  2543  	return imgConfig.Width, imgConfig.Height
  2544  }
  2545  
  2546  // Stop shuts down the server gracefully.
  2547  func (s *Server) Stop() {
  2548  	fmt.Println("Shutting down...")
  2549  
  2550  	// Stop serving new web requests.
  2551  	if s.httpsServer != nil {
  2552  		s.httpsServer.Shutdown(context.Background())
  2553  	} else if s.httpServer != nil {
  2554  		s.httpServer.Shutdown(context.Background())
  2555  	}
  2556  
  2557  	// Wait until existing web requests finish processing.
  2558  	s.lock.Lock()
  2559  	s.rebuildLock.Lock()
  2560  	s.rebuildQueue <- nil
  2561  	s.rebuildWaitGroup.Wait()
  2562  
  2563  	// Flush notification queue.
  2564  	if s.opt.Notifications {
  2565  		s.shutdownNotifications <- struct{}{}
  2566  	}
  2567  
  2568  	// If the HTTP server hasn't started yet, exit immediately.
  2569  	if s.httpServer == nil && s.httpsServer == nil {
  2570  		os.Exit(0)
  2571  	}
  2572  }
  2573  
  2574  // pluginByName returns the specified plugin instance and associated plugin information.
  2575  func pluginByName(name string) (any, *pluginInfo) {
  2576  	name = strings.ToLower(name)
  2577  	for i, info := range allPluginInfo {
  2578  		if strings.ToLower(info.Name) == name {
  2579  			return allPlugins[i], info
  2580  		}
  2581  	}
  2582  	return nil, nil
  2583  }
  2584  
  2585  // FormatValue formats a value as a human-readable string.
  2586  func FormatValue(v interface{}) interface{} {
  2587  	if role, ok := v.(AccountRole); ok {
  2588  		return FormatRole(role)
  2589  	} else if t, ok := v.(BoardType); ok {
  2590  		return FormatBoardType(t)
  2591  	} else if t, ok := v.(BoardHide); ok {
  2592  		return FormatBoardHide(t)
  2593  	} else if t, ok := v.(BoardLock); ok {
  2594  		return FormatBoardLock(t)
  2595  	} else if t, ok := v.(BoardApproval); ok {
  2596  		return FormatBoardApproval(t)
  2597  	} else if t, ok := v.(BoardIdentifiers); ok {
  2598  		return FormatBoardIdentifiers(t)
  2599  	}
  2600  	return v
  2601  }
  2602  
  2603  // printChanges returns the difference between two structs as a human-readable string.
  2604  func printChanges(old interface{}, new interface{}) string {
  2605  	const mask = "***"
  2606  	diff, err := diff.Diff(old, new)
  2607  	if err != nil {
  2608  		log.Fatal(err)
  2609  	} else if len(diff) == 0 {
  2610  		return ""
  2611  	}
  2612  	var label string
  2613  	for _, change := range diff {
  2614  		from := change.From
  2615  		to := change.To
  2616  
  2617  		var name string
  2618  		if len(change.Path) > 0 {
  2619  			name = change.Path[0]
  2620  			if name == "Password" {
  2621  				from = mask
  2622  				to = mask
  2623  			}
  2624  		}
  2625  
  2626  		label += fmt.Sprintf(` [%s: "%v" > "%v"]`, name, FormatValue(from), FormatValue(to))
  2627  	}
  2628  	return label
  2629  }
  2630  
  2631  // pageCount returns the number of pages required to display the provided number of items.
  2632  func pageCount(items int, pageSize int) int {
  2633  	if items == 0 || pageSize == 0 {
  2634  		return 1
  2635  	}
  2636  	pages := items / pageSize
  2637  	if items%pageSize != 0 {
  2638  		pages++
  2639  	}
  2640  	return pages
  2641  }
  2642  
  2643  func pageSlice[S ~[]T, T any](slice S, page int, perPage int) S {
  2644  	start := page * perPage
  2645  	end := len(slice)
  2646  	if perPage != 0 && end > start+perPage {
  2647  		end = start + perPage
  2648  	}
  2649  	return slice[start:end]
  2650  }
  2651  
  2652  // doctypePrefx is an HTML prefix which may be used in custom pages to skip
  2653  // including the default page header and footer templates.
  2654  const doctypePrefx = "<!DOCTYPE html>"
  2655  
  2656  // siteIndexHTML is an HTML page written to index.html when such a file does not already exist.
  2657  var siteIndexHTML = []byte(`
  2658  <!DOCTYPE html>
  2659  <html>
  2660  	<body>
  2661  		<meta http-equiv="refresh" content="0; url=/sriracha/">
  2662  		<a href="/sriracha/">Redirecting...</a>
  2663  	</body>
  2664  </html>
  2665  `)
  2666  

View as plain text