...

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  	"bytes"
     6  	"context"
     7  	"crypto/md5"
     8  	"crypto/sha512"
     9  	"crypto/tls"
    10  	"embed"
    11  	"encoding/base64"
    12  	"flag"
    13  	"fmt"
    14  	"html/template"
    15  	"image"
    16  	"io"
    17  	"io/fs"
    18  	"log"
    19  	"maps"
    20  	"net"
    21  	"net/http"
    22  	"net/smtp"
    23  	"net/url"
    24  	"os"
    25  	"os/signal"
    26  	"path"
    27  	"path/filepath"
    28  	"regexp"
    29  	"runtime/debug"
    30  	"slices"
    31  	"strconv"
    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/sys/unix"
    45  	"golang.org/x/text/language"
    46  	"golang.org/x/text/language/display"
    47  	"gopkg.in/yaml.v3"
    48  )
    49  
    50  // SrirachaVersion is the version of the software. In official releases, this
    51  // variable is replaced during compilation with the version of the release.
    52  var SrirachaVersion = "DEV"
    53  
    54  //go:embed locale
    55  var localeFS embed.FS
    56  
    57  // Default server settings.
    58  const (
    59  	defaultServerSiteName     = "Sriracha"
    60  	defaultServerSiteHome     = "/"
    61  	defaultServerOekakiWidth  = 540
    62  	defaultServerOekakiHeight = 540
    63  	defaultServerRefresh      = 30
    64  )
    65  
    66  // defaultServerEmbeds is a list of default oEmbed services.
    67  var defaultServerEmbeds = [][2]string{
    68  	{"YouTube", "https://youtube.com/oembed?format=json&url=SRIRACHA_EMBED"},
    69  	{"Vimeo", "https://vimeo.com/api/oembed.json?url=SRIRACHA_EMBED"},
    70  	{"SoundCloud", "https://soundcloud.com/oembed?format=json&url=SRIRACHA_EMBED"},
    71  }
    72  
    73  // Banner options.
    74  const (
    75  	bannerOverboard = -1
    76  	bannerNews      = -2
    77  	bannerPages     = -3
    78  )
    79  
    80  // NewsOption represents a News setting option.
    81  type NewsOption int
    82  
    83  // News options.
    84  const (
    85  	NewsDisable      NewsOption = 0
    86  	NewsWriteToNews  NewsOption = 1
    87  	NewsWriteToIndex NewsOption = 2
    88  )
    89  
    90  // ServerOptions represents server configuration options and related data.
    91  type ServerOptions struct {
    92  	SiteName         string
    93  	SiteHome         string
    94  	News             NewsOption
    95  	BoardIndex       bool
    96  	CAPTCHA          bool
    97  	Refresh          int
    98  	Uploads          []*UploadType
    99  	Embeds           [][2]string
   100  	OekakiWidth      int
   101  	OekakiHeight     int
   102  	Overboard        string
   103  	OverboardType    BoardType
   104  	OverboardThreads int
   105  	OverboardReplies int
   106  	Identifiers      bool
   107  	Locale           string
   108  	Locales          map[string]string
   109  	LocalesSorted    []string
   110  	Access           map[string]string
   111  	Banners          map[int][]*Banner
   112  	Notifications    bool
   113  	DevMode          bool
   114  	FuncMaps         map[string]template.FuncMap
   115  }
   116  
   117  // DefaultLocaleName returns the name of the configured default locale.
   118  func (opt *ServerOptions) DefaultLocaleName() string {
   119  	if opt.Locale == "" || opt.Locale == "en" {
   120  		return "English"
   121  	}
   122  	name := opt.Locales[opt.Locale]
   123  	if name != "" {
   124  		return name
   125  	}
   126  	return opt.Locale
   127  }
   128  
   129  // rebuildInfo contains information used to request rebuilding a thread.
   130  type rebuildInfo struct {
   131  	post *Post
   132  	wg   *sync.WaitGroup
   133  }
   134  
   135  // Server is the Sriracha imageboard and forum server.
   136  type Server struct {
   137  	Boards []*Board
   138  
   139  	rangeBans map[*Ban]*regexp.Regexp
   140  
   141  	config *Config
   142  	dbPool *pgxpool.Pool
   143  	opt    ServerOptions
   144  
   145  	tpl             *template.Template
   146  	original        *template.Template
   147  	customTemplates []string
   148  
   149  	notifications          []notification
   150  	notificationsPattern   *regexp.Regexp
   151  	notificationsWaitGroup sync.WaitGroup
   152  	shutdownNotifications  chan struct{}
   153  
   154  	rebuildQueue     chan *rebuildInfo
   155  	rebuildWaitGroup sync.WaitGroup
   156  	rebuildLock      sync.Mutex
   157  
   158  	httpServer *http.Server
   159  
   160  	lock sync.Mutex
   161  }
   162  
   163  // NewServer returns a new server.
   164  func NewServer() *Server {
   165  	return &Server{
   166  		opt: ServerOptions{
   167  			Banners: make(map[int][]*Banner),
   168  		},
   169  		shutdownNotifications: make(chan struct{}),
   170  		rebuildQueue:          make(chan *rebuildInfo),
   171  	}
   172  }
   173  
   174  // parseBuildInfo parses version control information embedded in the binary
   175  // during compilation. This is only used in unofficial releases.
   176  func (s *Server) parseBuildInfo() {
   177  	if SrirachaVersion == "" {
   178  		SrirachaVersion = "DEV"
   179  	} else if SrirachaVersion != "DEV" {
   180  		return
   181  	}
   182  	info, ok := debug.ReadBuildInfo()
   183  	if !ok {
   184  		return
   185  	}
   186  	buildTag := info.Main.Version
   187  	if buildTag != "" && buildTag[0] == 'v' {
   188  		SrirachaVersion = buildTag[1:]
   189  		firstHyphen := strings.IndexRune(SrirachaVersion, '-')
   190  		firstPlus := strings.IndexRune(SrirachaVersion, '+')
   191  		if firstHyphen == -1 && firstPlus == -1 {
   192  			return
   193  		}
   194  		if firstHyphen != -1 {
   195  			SrirachaVersion = SrirachaVersion[:firstHyphen]
   196  			firstPlus = strings.IndexRune(SrirachaVersion, '+')
   197  		}
   198  		if firstPlus != -1 {
   199  			SrirachaVersion = SrirachaVersion[:firstPlus]
   200  		}
   201  		SrirachaVersion += "-DEV"
   202  	}
   203  	for _, setting := range info.Settings {
   204  		if setting.Key == "vcs.revision" {
   205  			revision := setting.Value
   206  			if len(revision) > 10 {
   207  				revision = revision[:10]
   208  			}
   209  			SrirachaVersion += "-" + revision
   210  			return
   211  		}
   212  	}
   213  }
   214  
   215  // forbidden returns whether a user is forbidden from performing an accion.
   216  // When forbidden, an error page is written to the web request automatically.
   217  func (s *Server) forbidden(w http.ResponseWriter, data *templateData, action string) bool {
   218  	var required AccountRole
   219  	switch s.config.Access[action] {
   220  	case "mod":
   221  		required = RoleMod
   222  	case "admin":
   223  		required = RoleAdmin
   224  	case "super-admin":
   225  		required = RoleSuperAdmin
   226  	}
   227  	return data.forbidden(w, required)
   228  }
   229  
   230  // parseConfig parses a YAML configuration file.
   231  func (s *Server) parseConfig(configFile string) error {
   232  	buf, err := os.ReadFile(configFile)
   233  	if err != nil {
   234  		return err
   235  	}
   236  
   237  	config := &Config{
   238  		Access: make(map[string]string),
   239  	}
   240  	err = yaml.Unmarshal(buf, config)
   241  	if err != nil {
   242  		return err
   243  	}
   244  
   245  	switch {
   246  	case config.Root == "":
   247  		return fmt.Errorf("root (lowercase!) must be set in %s to the root directory (where board files are written)", configFile)
   248  	case config.Serve == "":
   249  		return fmt.Errorf("serve (lowercase!) must be set in %s to the HTTP server listen address (hostname:port)", configFile)
   250  	case config.SaltData == "":
   251  		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)
   252  	case config.SaltPass == "":
   253  		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)
   254  	case config.SaltTrip == "":
   255  		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)
   256  	}
   257  
   258  	if config.DBURL == "" {
   259  		switch {
   260  		case config.Address == "":
   261  			return fmt.Errorf("address (lowercase!) must be set in %s to the database address (hostname:port)", configFile)
   262  		case config.Username == "":
   263  			return fmt.Errorf("username (lowercase!) must be set in %s to the database username", configFile)
   264  		case config.Password == "":
   265  			return fmt.Errorf("password (lowercase!) must be set in %s to the database password", configFile)
   266  		case config.DBName == "":
   267  			return fmt.Errorf("dbname (lowercase!) must be set in %s to the database name", configFile)
   268  		}
   269  	}
   270  
   271  	if config.Locale == "" {
   272  		config.Locale = "en"
   273  	}
   274  
   275  	if config.MailFrom != "" && ParseEmail(config.MailFrom) == "" {
   276  		return fmt.Errorf("mailfrom is not a valid email address: %s", config.MailFrom)
   277  	} else if config.MailReplyTo != "" && ParseEmail(config.MailReplyTo) == "" {
   278  		return fmt.Errorf("mailreplyto is not a valid email address: %s", config.MailReplyTo)
   279  	}
   280  
   281  	if config.Mentions <= 0 {
   282  		config.Mentions = 60
   283  	}
   284  	if config.Notifications <= 0 {
   285  		config.Notifications = 1440
   286  	}
   287  
   288  	defaultAccess := map[string]string{
   289  		"ban.add":        "mod",
   290  		"ban.shorten":    "admin",
   291  		"ban.lengthen":   "mod",
   292  		"ban.delete":     "admin",
   293  		"banfile.add":    "mod",
   294  		"banfile.delete": "admin",
   295  		"banner.add":     "admin",
   296  		"banner.update":  "admin",
   297  		"banner.delete":  "super-admin",
   298  		"board.add":      "admin",
   299  		"board.update":   "admin",
   300  		"board.delete":   "super-admin",
   301  		"keyword.add":    "admin",
   302  		"keyword.update": "admin",
   303  		"keyword.delete": "admin",
   304  		"page.add":       "admin",
   305  		"page.update":    "admin",
   306  		"page.delete":    "admin",
   307  		"post.sticky":    "mod",
   308  		"post.lock":      "mod",
   309  		"post.move":      "mod",
   310  		"post.delete":    "mod",
   311  	}
   312  	validateAccess := func(name string, v string) error {
   313  		if _, ok := defaultAccess[name]; !ok && name != "default" {
   314  			return fmt.Errorf("access configuration contains unrecognized action %s", name)
   315  		}
   316  		switch v {
   317  		case "mod", "admin", "super-admin", "disable":
   318  			return nil
   319  		default:
   320  			return fmt.Errorf("action %s has unknown access level %s: must be 'mod', 'admin', 'super-admin' or 'disable'", name, v)
   321  		}
   322  	}
   323  	var defaultRequirement string
   324  	for name, v := range config.Access {
   325  		err = validateAccess(name, v)
   326  		if err != nil {
   327  			return fmt.Errorf("access configuration is invalid: %s", err)
   328  		} else if name == "default" {
   329  			defaultRequirement = v
   330  			delete(config.Access, name)
   331  		}
   332  	}
   333  	for name, v := range defaultAccess {
   334  		if config.Access[name] != "" {
   335  			continue
   336  		} else if defaultRequirement != "" {
   337  			config.Access[name] = defaultRequirement
   338  			continue
   339  		}
   340  		config.Access[name] = v
   341  	}
   342  
   343  	s.config = config
   344  	s.config.ImportMode = s.config.Import.Enabled()
   345  
   346  	if s.config.MailDomains != "" {
   347  		s.notificationsPattern, err = regexp.Compile(s.config.MailDomains)
   348  		if err != nil {
   349  			return fmt.Errorf("failed to parse maildomains regular expression: %s", err)
   350  		}
   351  	}
   352  	return nil
   353  }
   354  
   355  // parseLocales parses locale files.
   356  func (s *Server) parseLocales() error {
   357  	return fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
   358  		if err != nil {
   359  			return err
   360  		} else if d.IsDir() || !strings.HasSuffix(p, ".po") {
   361  			return nil
   362  		}
   363  		id := filepath.Base(strings.TrimSuffix(p, ".po"))
   364  
   365  		buf, err := localeFS.ReadFile(fmt.Sprintf("locale/%s/%s.po", id, id))
   366  		if err != nil {
   367  			return fmt.Errorf("failed to load locale %s: %s", id, err)
   368  		}
   369  
   370  		po := gotext.NewPo()
   371  		po.Parse(buf)
   372  		gotext.GetStorage().AddTranslator(fmt.Sprintf("sriracha-%s", id), po)
   373  		return nil
   374  	})
   375  }
   376  
   377  // connectToMailServer connects to the configured mail server and returns a SMTP client.
   378  func (s *Server) connectToMailServer() (*smtp.Client, error) {
   379  	if s.config.MailAddress == "" {
   380  		return nil, nil // Email notifications are disabled.
   381  	}
   382  
   383  	// Parse hostname and set default port.
   384  	address := s.config.MailAddress
   385  	hostname, _, err := net.SplitHostPort(s.config.MailAddress)
   386  	if err != nil {
   387  		hostname = s.config.MailAddress
   388  		if strings.ContainsRune(s.config.MailAddress, ':') {
   389  			address = fmt.Sprintf("[%s]:25", address)
   390  		} else {
   391  			address = address + ":25"
   392  		}
   393  	}
   394  	tlsConfig := &tls.Config{
   395  		InsecureSkipVerify: s.config.MailInsecure,
   396  		ServerName:         hostname,
   397  	}
   398  
   399  	// Connect to mail server.
   400  	var conn net.Conn
   401  	if s.config.MailTLS {
   402  		conn, err = tls.Dial("tcp", address, tlsConfig)
   403  		if err != nil {
   404  			log.Fatalf("failed to connect to SMTP server with TLS enabled at %s: %s", address, err)
   405  		}
   406  	} else {
   407  		conn, err = net.Dial("tcp", address)
   408  		if err != nil {
   409  			log.Fatalf("failed to connect to SMTP server without TLS enabled at %s: %s", address, err)
   410  		}
   411  	}
   412  
   413  	// Initialize client,
   414  	client, err := smtp.NewClient(conn, hostname)
   415  	if err != nil {
   416  		conn.Close()
   417  		return nil, fmt.Errorf("failed to initialize SMTP client: %s", err)
   418  	}
   419  
   420  	// Upgrade to TLS connection when available.
   421  	if !s.config.MailTLS {
   422  		ok, _ := client.Extension("STARTTLS")
   423  		if ok {
   424  			err = client.StartTLS(tlsConfig)
   425  			if err != nil {
   426  				client.Close()
   427  				return nil, fmt.Errorf("failed to upgrade plain text connection to TLS even though support for it was advertised")
   428  			}
   429  		}
   430  	}
   431  
   432  	// Authenticate.
   433  	var auth smtp.Auth
   434  	switch s.config.MailAuth {
   435  	case "challenge":
   436  		auth = smtp.CRAMMD5Auth(s.config.MailUsername, s.config.MailPassword)
   437  	case "plain":
   438  		auth = smtp.PlainAuth("", s.config.MailUsername, s.config.MailPassword, hostname)
   439  	case "", "none":
   440  		// Do nothing.
   441  	default:
   442  		client.Close()
   443  		return nil, fmt.Errorf("unrecognized mailauth configuration value %s: must be challenge / plain / none", s.config.MailAuth)
   444  	}
   445  	if auth != nil {
   446  		err := client.Auth(auth)
   447  		if err != nil {
   448  			client.Close()
   449  			return nil, fmt.Errorf("failed to authenticate with SMTP server: %s", err)
   450  		}
   451  	}
   452  
   453  	// Send NOOP command to verify connection and authentication were successful.
   454  	if err = client.Noop(); err != nil {
   455  		client.Close()
   456  		return nil, fmt.Errorf("failed to verify SMTP server connection by sending NOOP command: %s", err)
   457  	}
   458  	return client, nil
   459  }
   460  
   461  // sendMail sends an email.
   462  func (s *Server) sendMail(client *smtp.Client, recipient string, subject string, message string) error {
   463  	// Build mail body.
   464  	var body []byte
   465  	if s.config.MailFrom != "" {
   466  		body = fmt.Appendf(body, "From: %s\n", s.config.MailFrom)
   467  	}
   468  	body = fmt.Appendf(body, "To: %s\nSubject: %s\n", recipient, subject)
   469  	if s.config.MailReplyTo != "" {
   470  		body = fmt.Appendf(body, "Reply-To: %s\n", s.config.MailReplyTo)
   471  	}
   472  	body = fmt.Appendf(body, "\n%s", message)
   473  
   474  	// Reset state.
   475  	if err := client.Reset(); err != nil {
   476  		return fmt.Errorf("failed to reset state: %s", err)
   477  	}
   478  
   479  	// Set "From" and "To" addresses.
   480  	if s.config.MailFrom != "" {
   481  		if err := client.Mail(s.config.MailFrom); err != nil {
   482  			return fmt.Errorf("failed to set from address: %s", err)
   483  		}
   484  	}
   485  	if err := client.Rcpt(recipient); err != nil {
   486  		return fmt.Errorf("failed to set recipient address: %s", err)
   487  	}
   488  
   489  	// Initiate data transfer.
   490  	wc, err := client.Data()
   491  	if err != nil {
   492  		return fmt.Errorf("failed to send DATA command: %s", err)
   493  	}
   494  
   495  	// Write mail body.
   496  	_, err = wc.Write(body)
   497  	if err != nil {
   498  		return fmt.Errorf("failed to write email body: %s", err)
   499  	}
   500  
   501  	// Complete data transfer.
   502  	err = wc.Close()
   503  	if err != nil {
   504  		return fmt.Errorf("failed to write email body: %s", err)
   505  	}
   506  	return nil
   507  }
   508  
   509  // begin acquires a database connection from the pool and starts a transaction.
   510  func (s *Server) begin() *database.DB {
   511  	return database.Begin(s.dbPool, s.config)
   512  }
   513  
   514  // setDefaultServerConfig loads the server configuration and sets default values.
   515  func (s *Server) setDefaultServerConfig() error {
   516  	db := s.begin()
   517  	defer db.Commit()
   518  
   519  	siteName := db.GetString("sitename")
   520  	if siteName == "" {
   521  		siteName = defaultServerSiteName
   522  	}
   523  	s.opt.SiteName = siteName
   524  
   525  	siteHome := db.GetString("sitehome")
   526  	if siteHome == "" {
   527  		siteHome = defaultServerSiteHome
   528  	}
   529  	s.opt.SiteHome = siteHome
   530  
   531  	news := NewsOption(db.GetInt("news"))
   532  	if news == NewsDisable || news == NewsWriteToNews || news == NewsWriteToIndex {
   533  		s.opt.News = news
   534  	}
   535  
   536  	boardIndex := db.GetString("boardindex")
   537  	s.opt.BoardIndex = boardIndex == "" || boardIndex == "1"
   538  
   539  	s.opt.CAPTCHA = db.GetBool("captcha")
   540  
   541  	oekakiWidth := db.GetInt("oekakiwidth")
   542  	if oekakiWidth == 0 {
   543  		oekakiWidth = defaultServerOekakiWidth
   544  	}
   545  	s.opt.OekakiWidth = oekakiWidth
   546  
   547  	oekakiHeight := db.GetInt("oekakiheight")
   548  	if oekakiHeight == 0 {
   549  		oekakiHeight = defaultServerOekakiHeight
   550  	}
   551  	s.opt.OekakiHeight = oekakiHeight
   552  
   553  	if !db.HaveConfig("refresh") {
   554  		s.opt.Refresh = defaultServerRefresh
   555  	} else {
   556  		s.opt.Refresh = db.GetInt("refresh")
   557  	}
   558  
   559  	s.opt.Overboard = db.GetString("overboard")
   560  	s.opt.OverboardType = BoardType(db.GetInt("overboardtype"))
   561  	s.opt.OverboardThreads = db.GetInt("overboardthreads")
   562  	s.opt.OverboardReplies = db.GetInt("overboardreplies")
   563  
   564  	s.opt.Uploads = s.config.UploadTypes()
   565  
   566  	s.opt.Embeds = nil
   567  	if !db.HaveConfig("embeds") {
   568  		s.opt.Embeds = append(s.opt.Embeds, defaultServerEmbeds...)
   569  	} else {
   570  		embeds := db.GetMultiString("embeds")
   571  		for _, v := range embeds {
   572  			split := strings.SplitN(v, " ", 2)
   573  			if len(split) != 2 {
   574  				continue
   575  			}
   576  			s.opt.Embeds = append(s.opt.Embeds, [2]string{split[0], split[1]})
   577  		}
   578  	}
   579  
   580  	s.reloadBans(db)
   581  
   582  	s.opt.Identifiers = s.config.Identifiers
   583  
   584  	s.opt.Locale = s.config.Locale
   585  
   586  	s.opt.Locales = make(map[string]string)
   587  	english := display.English.Languages()
   588  	fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
   589  		if err != nil {
   590  			return err
   591  		} else if d.IsDir() || !strings.HasSuffix(p, ".po") {
   592  			return nil
   593  		}
   594  		id := filepath.Base(strings.TrimSuffix(p, ".po"))
   595  
   596  		name := id
   597  		tag, err := language.Parse(id)
   598  		if err == nil {
   599  			tagName := english.Name(tag)
   600  			if tagName != "" {
   601  				name = tagName
   602  			}
   603  		}
   604  
   605  		s.opt.Locales[id] = name
   606  		return nil
   607  	})
   608  	s.opt.Locales["en@pirate"] = "Pirate English"
   609  	if s.opt.Locale != "" && s.opt.Locale != "en" {
   610  		s.opt.Locales["en"] = "English"
   611  	}
   612  	s.opt.LocalesSorted = slices.SortedFunc(maps.Keys(s.opt.Locales), func(s1, s2 string) int {
   613  		return strings.Compare(s.opt.Locales[s1], s.opt.Locales[s2])
   614  	})
   615  
   616  	templateFuncMaps = make(map[string]template.FuncMap)
   617  	templateFuncMaps[""] = newTemplateFuncMap(s.opt.Locale)
   618  	for id := range s.opt.Locales {
   619  		templateFuncMaps[id] = newTemplateFuncMap(id)
   620  	}
   621  
   622  	s.opt.Access = make(map[string]string)
   623  	maps.Copy(s.opt.Access, s.config.Access)
   624  	return nil
   625  }
   626  
   627  // setDefaultPluginConfig sets default plugin configuration values.
   628  func (s *Server) setDefaultPluginConfig() error {
   629  	db := s.begin()
   630  	defer db.Commit()
   631  
   632  	for i, info := range allPluginInfo {
   633  		db.Plugin = info.Name
   634  
   635  		for i, config := range info.Config {
   636  			if !db.HaveConfig(config.Name) {
   637  				db.SaveString(config.Name, config.Value)
   638  			} else {
   639  				info.Config[i].Value = db.GetString(config.Name)
   640  			}
   641  		}
   642  
   643  		p := allPlugins[i]
   644  		pUpdate, ok := p.(sriracha.PluginWithUpdate)
   645  		if ok {
   646  			for _, config := range info.Config {
   647  				pUpdate.Update(db, config.Name)
   648  			}
   649  		}
   650  	}
   651  	return nil
   652  }
   653  
   654  // loadPluginConfig loads plugin configuration options.
   655  func (s *Server) loadPluginConfig() error {
   656  	db := s.begin()
   657  	defer db.Commit()
   658  
   659  	for _, info := range allPluginInfo {
   660  		db.Plugin = info.Name
   661  		for i, c := range info.Config {
   662  			v := db.GetString(strings.ToLower(info.Name + "." + c.Name))
   663  			if v != "" {
   664  				info.Config[i].Value = v
   665  			}
   666  		}
   667  	}
   668  	db.Plugin = ""
   669  	return nil
   670  }
   671  
   672  // officialTemplateDir searches for the path to the official template directory and returns it.
   673  func (s *Server) officialTemplateDir() string {
   674  	officialDir := "internal/server/template"
   675  	_, err := os.Stat(officialDir)
   676  	if !os.IsNotExist(err) {
   677  		return officialDir
   678  	}
   679  	officialDir = "template"
   680  	_, err = os.Stat(officialDir)
   681  	if !os.IsNotExist(err) {
   682  		return officialDir
   683  	}
   684  	return ""
   685  }
   686  
   687  // validateTemplateConfig validates whether the official and custom template
   688  // directories are unique and accessible.
   689  func (s *Server) validateTemplateConfig(officialDir string) error {
   690  	if s.config.Template == "" {
   691  		return nil
   692  	}
   693  	_, err := os.Stat(s.config.Template)
   694  	if os.IsNotExist(err) {
   695  		return fmt.Errorf("custom template directory %s does not exist", s.config.Template)
   696  	}
   697  	officialTemplate, err := os.Stat(officialDir)
   698  	if err != nil {
   699  		return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
   700  	}
   701  	customTemplate, err := os.Stat(s.config.Template)
   702  	if err != nil {
   703  		return fmt.Errorf("custom template directory %s is inaccessible", s.config.Template)
   704  	}
   705  	if os.SameFile(officialTemplate, customTemplate) {
   706  		return fmt.Errorf("official templates and custom templates must be located in separate directories")
   707  	}
   708  	return nil
   709  }
   710  
   711  // parseTemplates parses official and custom templates. Provide an empty
   712  // officialDir to load official templates from the embedded file system.
   713  // Otherwise, official templates are loaded from disk. When customDir is set,
   714  // custom templates are loaded from disk.
   715  func (s *Server) parseTemplates(officialDir string, customDir string) error {
   716  	s.customTemplates = s.customTemplates[:0]
   717  	wrapError := func(name string, err error) error {
   718  		var source string
   719  		if !slices.Contains(s.customTemplates, name) {
   720  			source = "official"
   721  		} else {
   722  			source = "custom"
   723  		}
   724  		return fmt.Errorf("failed to parse %s template file %s: %s", source, name, err)
   725  	}
   726  	parseDir := func(dir string, custom bool) error {
   727  		entries, err := os.ReadDir(dir)
   728  		if err != nil {
   729  			return err
   730  		}
   731  		for _, f := range entries {
   732  			if !strings.HasSuffix(f.Name(), ".gohtml") {
   733  				continue
   734  			} else if custom {
   735  				s.customTemplates = append(s.customTemplates, f.Name())
   736  			}
   737  
   738  			buf, err := os.ReadFile(filepath.Join(dir, f.Name()))
   739  			if err != nil {
   740  				return wrapError(f.Name(), err)
   741  			}
   742  
   743  			_, err = s.tpl.New(f.Name()).Parse(string(buf))
   744  			if err != nil {
   745  				return wrapError(f.Name(), err)
   746  			}
   747  		}
   748  		return nil
   749  	}
   750  	if officialDir == "" {
   751  		s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
   752  
   753  		entries, err := templateFS.ReadDir("template")
   754  		if err != nil {
   755  			return err
   756  		}
   757  		for _, f := range entries {
   758  			if !strings.HasSuffix(f.Name(), ".gohtml") {
   759  				continue
   760  			}
   761  
   762  			buf, err := templateFS.ReadFile(filepath.Join("template", f.Name()))
   763  			if err != nil {
   764  				return wrapError(f.Name(), err)
   765  			}
   766  
   767  			_, err = s.tpl.New(f.Name()).Parse(string(buf))
   768  			if err != nil {
   769  				return wrapError(f.Name(), err)
   770  			}
   771  		}
   772  	} else {
   773  		s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
   774  
   775  		err := parseDir(officialDir, false)
   776  		if err != nil {
   777  			return err
   778  		}
   779  	}
   780  
   781  	if customDir != "" {
   782  		err := parseDir(customDir, true)
   783  		if err != nil {
   784  			return err
   785  		}
   786  	}
   787  
   788  	var err error
   789  	s.original, err = s.tpl.Clone()
   790  	return err
   791  }
   792  
   793  func (s *Server) _watchTemplates(officialDir string, watcher *fsnotify.Watcher) {
   794  	for {
   795  		select {
   796  		case event, ok := <-watcher.Events:
   797  			if !ok {
   798  				return
   799  			} else if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) {
   800  				continue
   801  			}
   802  			err := s.parseTemplates(officialDir, s.config.Template)
   803  			if err != nil {
   804  				log.Printf("failed to parse template files: %s", err)
   805  			}
   806  		case err, ok := <-watcher.Errors:
   807  			if !ok {
   808  				return
   809  			}
   810  			log.Printf("fsnotify error: %s", err)
   811  		}
   812  	}
   813  }
   814  
   815  // watchTemplates watches the official and custom template directories for changes.
   816  func (s *Server) watchTemplates(officialDir string) error {
   817  	watcher, err := fsnotify.NewWatcher()
   818  	if err != nil {
   819  		log.Fatal(err)
   820  	}
   821  	go s._watchTemplates(officialDir, watcher)
   822  
   823  	err = watcher.Add(officialDir)
   824  	if err == nil && s.config.Template != "" {
   825  		err = watcher.Add(s.config.Template)
   826  	}
   827  	return err
   828  }
   829  
   830  // log adds an entry to the audit log.
   831  func (s *Server) log(db *database.DB, account *Account, board *Board, action string, info string) {
   832  	user := "system"
   833  	if account != nil && account.ID != 0 {
   834  		if account.Role == RoleSuperAdmin || account.Role == RoleAdmin {
   835  			user = "admin"
   836  		} else {
   837  			user = "mod"
   838  		}
   839  	}
   840  	for _, handlerInfo := range allPluginAuditHandlers {
   841  		db.Plugin = handlerInfo.Name
   842  		err := handlerInfo.Handler(db, user, action, info)
   843  		if err != nil {
   844  			log.Fatalf("plugin %s failed to process audit event: %s", handlerInfo.Name, err)
   845  		}
   846  	}
   847  	db.Plugin = ""
   848  
   849  	db.AddLog(&Log{
   850  		Account: account,
   851  		Board:   board,
   852  		Message: action,
   853  		Changes: info,
   854  	})
   855  }
   856  
   857  // refreshBannerCache refreshes the banner cache.
   858  func (s *Server) refreshBannerCache(db *database.DB) {
   859  	banners := s.opt.Banners
   860  	for id := range banners {
   861  		banners[id] = banners[id][:0]
   862  	}
   863  
   864  	for _, banner := range db.AllBanners() {
   865  		for _, board := range banner.Boards {
   866  			banners[board.ID] = append(banners[board.ID], banner)
   867  		}
   868  		if banner.Overboard {
   869  			banners[bannerOverboard] = append(banners[bannerOverboard], banner)
   870  		}
   871  		if banner.News {
   872  			banners[bannerNews] = append(banners[bannerNews], banner)
   873  		}
   874  		if banner.Pages {
   875  			banners[bannerPages] = append(banners[bannerPages], banner)
   876  		}
   877  	}
   878  
   879  	for id := range banners {
   880  		if len(banners[id]) == 0 {
   881  			delete(banners, id)
   882  		}
   883  	}
   884  }
   885  
   886  // deletePostFiles deletes files associated with a post.
   887  func (s *Server) deletePostFiles(p *Post) {
   888  	if p.Board == nil {
   889  		return
   890  	} else if p.ID != 0 && p.Parent == 0 {
   891  		os.Remove(filepath.Join(s.config.Root, p.Board.Dir, "res", fmt.Sprintf("%d.html", p.ID)))
   892  	}
   893  
   894  	if p.File == "" {
   895  		return
   896  	}
   897  	srcPath := filepath.Join(s.config.Root, p.Board.Dir, "src", p.File)
   898  	os.Remove(srcPath)
   899  
   900  	if p.Thumb == "" {
   901  		return
   902  	}
   903  	thumbPath := filepath.Join(s.config.Root, p.Board.Dir, "thumb", p.Thumb)
   904  	os.Remove(thumbPath)
   905  }
   906  
   907  // deletePost deletes a post from the database as well as any associated files.
   908  func (s *Server) deletePost(db *database.DB, p *Post) {
   909  	posts := db.AllPostsInThread(p.ID, false)
   910  	for _, post := range posts {
   911  		s.deletePostFiles(post)
   912  	}
   913  
   914  	db.DeletePost(p.ID)
   915  }
   916  
   917  // buildData returns a new template data instance.
   918  func (s *Server) buildData(db *database.DB, w http.ResponseWriter, r *http.Request) *templateData {
   919  	if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
   920  		http.SetCookie(w, &http.Cookie{
   921  			Name:  "sriracha_session",
   922  			Value: "",
   923  			Path:  "/",
   924  		})
   925  		http.Redirect(w, r, "/sriracha/", http.StatusFound)
   926  		return s.newTemplateData()
   927  	}
   928  
   929  	if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
   930  		var failedLogin bool
   931  		username := r.FormValue("username")
   932  		if len(username) != 0 {
   933  			failedLogin = true
   934  			password := r.FormValue("password")
   935  			if len(password) != 0 {
   936  				if !s.opt.DevMode {
   937  					// Verify CAPTCHA.
   938  					var solved bool
   939  					ipHash := s.hashIP(r)
   940  					challenge := db.GetCAPTCHA(ipHash)
   941  					if challenge != nil {
   942  						solution := FormString(r, "captcha")
   943  						if strings.ToLower(solution) == challenge.Text {
   944  							solved = true
   945  							db.DeleteCAPTCHA(ipHash)
   946  							os.Remove(filepath.Join(s.config.Root, "captcha", challenge.Image+".png"))
   947  						}
   948  					}
   949  					if !solved {
   950  						data := s.newTemplateData()
   951  						data.Info = "Invalid CAPTCHA."
   952  						data.Template = "manage_error"
   953  						return data
   954  					}
   955  				}
   956  
   957  				// Verify username and password.
   958  				account := db.LoginAccount(username, password)
   959  				if account != nil {
   960  					http.SetCookie(w, &http.Cookie{
   961  						Name:  "sriracha_session",
   962  						Value: account.Session,
   963  						Path:  "/",
   964  					})
   965  					if s.config.ImportMode {
   966  						http.Redirect(w, r, "/sriracha/import/", http.StatusFound)
   967  					}
   968  					data := s.newTemplateData()
   969  					data.Account = account
   970  					return data
   971  				}
   972  			}
   973  		}
   974  		if failedLogin {
   975  			data := s.newTemplateData()
   976  			data.Info = "Invalid username or password."
   977  			data.Template = "manage_error"
   978  			return data
   979  		}
   980  	}
   981  
   982  	cookies := r.CookiesNamed("sriracha_session")
   983  	if len(cookies) > 0 {
   984  		account := db.AccountBySessionKey(cookies[0].Value)
   985  		if account != nil {
   986  			data := s.newTemplateData()
   987  			data.Account = account
   988  			return data
   989  		}
   990  	}
   991  	return s.newTemplateData()
   992  }
   993  
   994  // writeThread writes a thread res page to disk.
   995  func (s *Server) writeThread(db *database.DB, board *Board, postID int) {
   996  	posts := db.AllPostsInThread(postID, true)
   997  	if len(posts) == 0 {
   998  		return
   999  	}
  1000  
  1001  	if board.Unique == 0 {
  1002  		board.Unique = db.UniqueUserPosts(board)
  1003  	}
  1004  
  1005  	f, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("%d.html", postID)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1006  	if err != nil {
  1007  		log.Fatal(err)
  1008  	}
  1009  
  1010  	data := s.newTemplateData()
  1011  	data.Board = board
  1012  	data.Boards = db.AllBoards()
  1013  	data.Threads = [][]*Post{posts}
  1014  	data.ReplyMode = postID
  1015  	data.Template = "board_page"
  1016  	data.execute(f)
  1017  }
  1018  
  1019  // writeIndexes writes board index pages to disk.
  1020  func (s *Server) writeIndexes(db *database.DB, board *Board) {
  1021  	if board.Unique == 0 {
  1022  		board.Unique = db.UniqueUserPosts(board)
  1023  	}
  1024  
  1025  	data := s.newTemplateData()
  1026  	data.Board = board
  1027  	data.Boards = db.AllBoards()
  1028  	data.ReplyMode = 1
  1029  	data.Template = "board_catalog"
  1030  
  1031  	threadInfo := db.AllThreads(board, true)
  1032  
  1033  	// Write catalog.
  1034  	if board.Type == TypeImageboard {
  1035  		catalogFile, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, "catalog.html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1036  		if err != nil {
  1037  			log.Fatal(err)
  1038  		}
  1039  
  1040  		for _, info := range threadInfo {
  1041  			thread := db.PostByID(info[0])
  1042  			thread.Replies = info[1]
  1043  			data.Threads = append(data.Threads, []*Post{thread})
  1044  		}
  1045  		data.execute(catalogFile)
  1046  
  1047  		catalogFile.Close()
  1048  	}
  1049  
  1050  	// Write indexes.
  1051  
  1052  	data.ReplyMode = 0
  1053  	data.Template = "board_page"
  1054  	data.Pages = pageCount(len(threadInfo), board.Threads)
  1055  	for page := 0; page < data.Pages; page++ {
  1056  		fileName := "index.html"
  1057  		if page > 0 {
  1058  			fileName = fmt.Sprintf("%d.html", page)
  1059  		}
  1060  
  1061  		indexFile, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1062  		if err != nil {
  1063  			log.Fatal(err)
  1064  		}
  1065  
  1066  		start := page * board.Threads
  1067  		end := len(threadInfo)
  1068  		if board.Threads != 0 && end > start+board.Threads {
  1069  			end = start + board.Threads
  1070  		}
  1071  
  1072  		data.Threads = data.Threads[:0]
  1073  		for _, info := range threadInfo[start:end] {
  1074  			thread := db.PostByID(info[0])
  1075  			thread.Replies = info[1]
  1076  			posts := []*Post{thread}
  1077  			if board.Type == TypeImageboard {
  1078  				posts = append(posts, db.AllReplies(thread.ID, board.Replies, true)...)
  1079  			}
  1080  			data.Threads = append(data.Threads, posts)
  1081  		}
  1082  		data.Page = page
  1083  		data.execute(indexFile)
  1084  
  1085  		indexFile.Close()
  1086  	}
  1087  }
  1088  
  1089  // writeOverboard writes overboard pages to disk.
  1090  func (s *Server) writeOverboard(db *database.DB) {
  1091  	var overboardDir string
  1092  	if s.opt.Overboard != "/" {
  1093  		overboardDir = s.opt.Overboard
  1094  	}
  1095  
  1096  	overboard := &Board{
  1097  		ID:      -1,
  1098  		Type:    s.opt.OverboardType,
  1099  		Name:    gotext.Get("Overboard"),
  1100  		Dir:     overboardDir,
  1101  		Threads: s.opt.OverboardThreads,
  1102  		Replies: s.opt.OverboardReplies,
  1103  	}
  1104  
  1105  	data := s.newTemplateData()
  1106  	data.Board = overboard
  1107  	data.Boards = db.AllBoards()
  1108  	data.ReplyMode = 1
  1109  	data.Template = "board_catalog"
  1110  
  1111  	threadInfo := db.AllThreads(nil, true)
  1112  
  1113  	// Write catalog.
  1114  	if overboard.Type == TypeImageboard {
  1115  		catalogFile, err := os.OpenFile(filepath.Join(s.config.Root, overboardDir, "catalog.html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1116  		if err != nil {
  1117  			log.Fatal(err)
  1118  		}
  1119  
  1120  		for _, info := range threadInfo {
  1121  			thread := db.PostByID(info[0])
  1122  			thread.Replies = info[1]
  1123  			data.Threads = append(data.Threads, []*Post{thread})
  1124  		}
  1125  		data.execute(catalogFile)
  1126  
  1127  		catalogFile.Close()
  1128  	}
  1129  
  1130  	// Write indexes.
  1131  
  1132  	data.ReplyMode = 0
  1133  	data.Template = "board_page"
  1134  	data.Pages = pageCount(len(threadInfo), overboard.Threads)
  1135  	for page := 0; page < data.Pages; page++ {
  1136  		fileName := "index.html"
  1137  		if page > 0 {
  1138  			fileName = fmt.Sprintf("%d.html", page)
  1139  		}
  1140  
  1141  		indexFile, err := os.OpenFile(filepath.Join(s.config.Root, overboardDir, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1142  		if err != nil {
  1143  			log.Fatal(err)
  1144  		}
  1145  
  1146  		start := page * overboard.Threads
  1147  		end := len(threadInfo)
  1148  		if overboard.Threads != 0 && end > start+overboard.Threads {
  1149  			end = start + overboard.Threads
  1150  		}
  1151  
  1152  		data.Threads = data.Threads[:0]
  1153  		for _, info := range threadInfo[start:end] {
  1154  			thread := db.PostByID(info[0])
  1155  			thread.Replies = info[1]
  1156  			posts := []*Post{thread}
  1157  			if overboard.Type == TypeImageboard {
  1158  				posts = append(posts, db.AllReplies(thread.ID, overboard.Replies, true)...)
  1159  			}
  1160  			data.Threads = append(data.Threads, posts)
  1161  		}
  1162  		data.Page = page
  1163  		data.execute(indexFile)
  1164  
  1165  		indexFile.Close()
  1166  	}
  1167  }
  1168  
  1169  // newPageTemplate returns a new collection of templates with read-only database access.
  1170  func (s *Server) newPageTemplate(db *database.DB) *template.Template {
  1171  	tpl, err := s.original.Clone()
  1172  	if err != nil {
  1173  		log.Fatal(err)
  1174  	}
  1175  	return tpl.Funcs(map[string]any{
  1176  		// Board.
  1177  		"BoardByID":       db.BoardByID,
  1178  		"BoardByDir":      db.BoardByDir,
  1179  		"UniqueUserPosts": db.UniqueUserPosts,
  1180  		"AllBoards":       db.AllBoards,
  1181  		// News.
  1182  		"NewsByID": db.NewsByID,
  1183  		"AllNews":  db.AllNews,
  1184  		// Post.
  1185  		"AllThreads":       db.AllThreads,
  1186  		"AllPostsInThread": db.AllPostsInThread,
  1187  		"AllReplies":       db.AllReplies,
  1188  		"PendingPosts":     db.PendingPosts,
  1189  		"PostByID":         db.PostByID,
  1190  		"PostsByIP":        db.PostsByIP,
  1191  		"PostsByFileHash":  db.PostsByFileHash,
  1192  		"PostByField":      db.PostByField,
  1193  		"LastPostByIP":     db.LastPostByIP,
  1194  		"ReplyCount":       db.ReplyCount,
  1195  	})
  1196  }
  1197  
  1198  // writePages writes custom pages to disk.
  1199  func (s *Server) writePages(db *database.DB, pages []*Page, dryRun bool) error {
  1200  	data := s.newTemplateData()
  1201  	data.Boards = db.AllBoards()
  1202  	data.Template = "page"
  1203  
  1204  	tpl := s.newPageTemplate(db)
  1205  	for _, p := range pages {
  1206  		err := p.Validate()
  1207  		if err != nil {
  1208  			if dryRun {
  1209  				return err
  1210  			}
  1211  			log.Printf("Warning: skipped invalid page %s: %s", p.Path, err)
  1212  			continue
  1213  		}
  1214  
  1215  		dir := filepath.Dir(p.Path)
  1216  		if dir != "" {
  1217  			dirPath := filepath.Join(s.config.Root, dir)
  1218  			_, err := os.Stat(dirPath)
  1219  			if os.IsNotExist(err) {
  1220  				os.MkdirAll(dirPath, NewDirPermission)
  1221  			}
  1222  		}
  1223  
  1224  		var w io.Writer
  1225  		var pageFile *os.File
  1226  		if dryRun {
  1227  			w = io.Discard
  1228  		} else {
  1229  			pageFile, err = os.OpenFile(filepath.Join(s.config.Root, p.Path+".html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1230  			if err != nil {
  1231  				log.Fatal(err)
  1232  			}
  1233  			w = pageFile
  1234  		}
  1235  
  1236  		data.tpl, err = tpl.Clone()
  1237  		if err != nil {
  1238  			log.Fatal(err)
  1239  		}
  1240  		data.tpl, err = data.tpl.New("line").Parse(p.Content)
  1241  		if err != nil {
  1242  			if dryRun {
  1243  				return err
  1244  			}
  1245  			log.Printf("Warning: failed to parse content of page %s: %s", p.Path, err)
  1246  			pageFile.Close()
  1247  			continue
  1248  		}
  1249  
  1250  		if strings.HasPrefix(p.Content, doctypePrefx) {
  1251  			data.Template = "line"
  1252  		} else {
  1253  			data.Template = "page"
  1254  		}
  1255  		err = data.executeWithError(w)
  1256  		if err != nil {
  1257  			if dryRun {
  1258  				return err
  1259  			}
  1260  			log.Printf("Warning: failed to render page %s: %s", p.Path, err)
  1261  			pageFile.Close()
  1262  			continue
  1263  		}
  1264  
  1265  		if !dryRun {
  1266  			pageFile.Close()
  1267  		}
  1268  	}
  1269  	return nil
  1270  }
  1271  
  1272  // rebuildBoard rebuilds a thread res page and board index pages.
  1273  func (s *Server) rebuildThread(db *database.DB, post *Post) {
  1274  	s.writeThread(db, post.Board, post.Thread())
  1275  	s.writeIndexes(db, post.Board)
  1276  	if s.opt.Overboard != "" {
  1277  		s.writeOverboard(db)
  1278  	}
  1279  }
  1280  
  1281  // rebuildBoard rebuilds all pages in a board.
  1282  func (s *Server) rebuildBoard(db *database.DB, board *Board) {
  1283  	for _, info := range db.AllThreads(board, true) {
  1284  		s.writeThread(db, board, info[0])
  1285  	}
  1286  	s.writeIndexes(db, board)
  1287  }
  1288  
  1289  // rebuildAll rebuilds all board, overboard, news and custom pages.
  1290  func (s *Server) rebuildAll(db *database.DB) {
  1291  	for _, b := range db.AllBoards() {
  1292  		s.rebuildBoard(db, b)
  1293  	}
  1294  
  1295  	if s.opt.Overboard != "" {
  1296  		s.writeOverboard(db)
  1297  	}
  1298  
  1299  	s.rebuildNews(db)
  1300  
  1301  	pages := db.AllPages()
  1302  	if len(pages) != 0 {
  1303  		s.writePages(db, pages, false)
  1304  	}
  1305  }
  1306  
  1307  // writeNewsItem writes a news entry page to disk.
  1308  func (s *Server) writeNewsItem(db *database.DB, n *News) {
  1309  	if n.ID <= 0 {
  1310  		return
  1311  	}
  1312  
  1313  	data := s.newTemplateData()
  1314  	data.Boards = db.AllBoards()
  1315  	data.Template = "news"
  1316  	data.AllNews = []*News{n}
  1317  	data.Pages = 1
  1318  	data.Extra = "view"
  1319  
  1320  	itemFile, err := os.OpenFile(filepath.Join(s.config.Root, fmt.Sprintf("news-%d.html", n.ID)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1321  	if err != nil {
  1322  		log.Fatal(err)
  1323  	}
  1324  	data.execute(itemFile)
  1325  	itemFile.Close()
  1326  }
  1327  
  1328  // writeNewsIndexes writes news index pages to disk.
  1329  func (s *Server) writeNewsIndexes(db *database.DB) {
  1330  	allNews := db.AllNews(true)
  1331  	data := s.newTemplateData()
  1332  	data.Boards = db.AllBoards()
  1333  	data.Template = "news"
  1334  
  1335  	const newsCount = 10
  1336  	data.Pages = pageCount(len(allNews), newsCount)
  1337  	for page := 0; page < data.Pages; page++ {
  1338  		fileName := "news.html"
  1339  		if s.opt.News == NewsWriteToIndex {
  1340  			fileName = "index.html"
  1341  		}
  1342  		if page > 0 {
  1343  			fileName = fmt.Sprintf("news-p%d.html", page)
  1344  		}
  1345  
  1346  		indexFile, err := os.OpenFile(filepath.Join(s.config.Root, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
  1347  		if err != nil {
  1348  			log.Fatal(err)
  1349  		}
  1350  
  1351  		start := page * newsCount
  1352  		end := len(allNews)
  1353  		if newsCount != 0 && end > start+newsCount {
  1354  			end = start + newsCount
  1355  		}
  1356  
  1357  		data.AllNews = allNews[start:end]
  1358  		data.Page = page
  1359  		data.execute(indexFile)
  1360  
  1361  		indexFile.Close()
  1362  	}
  1363  }
  1364  
  1365  // rebuildNewsItem rebuilds a news entry.
  1366  func (s *Server) rebuildNewsItem(db *database.DB, n *News) {
  1367  	s.writeNewsItem(db, n)
  1368  	s.writeNewsIndexes(db)
  1369  }
  1370  
  1371  // rebuildNews rebuilds all news entries.
  1372  func (s *Server) rebuildNews(db *database.DB) {
  1373  	for _, n := range db.AllNews(true) {
  1374  		s.writeNewsItem(db, n)
  1375  	}
  1376  	s.writeNewsIndexes(db)
  1377  }
  1378  
  1379  // reloadBans refreshes the range ban regular expression cache.
  1380  func (s *Server) reloadBans(db *database.DB) {
  1381  	var rangeBans = make(map[*Ban]*regexp.Regexp)
  1382  	bans := db.AllBans(true)
  1383  	for _, ban := range bans {
  1384  		pattern, err := regexp.Compile(ban.IP[2:])
  1385  		if err != nil {
  1386  			log.Printf("warning: failed to compile IP range ban `%s` as regular expression: %s", ban.IP[2:], err)
  1387  			return
  1388  		}
  1389  		rangeBans[ban] = pattern
  1390  	}
  1391  	s.rangeBans = rangeBans
  1392  }
  1393  
  1394  // serveManage serves management panel web requests.
  1395  func (s *Server) serveManage(db *database.DB, w http.ResponseWriter, r *http.Request) {
  1396  	data := s.buildData(db, w, r)
  1397  	if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
  1398  		return
  1399  	}
  1400  	var skipExecute bool
  1401  
  1402  	if len(data.Info) != 0 {
  1403  		data.Template = "manage_error"
  1404  		data.execute(w)
  1405  		return
  1406  	}
  1407  
  1408  	if strings.HasPrefix(r.URL.Path, "/sriracha/oekaki/") {
  1409  		postID := PathInt(r, "/sriracha/oekaki/")
  1410  		post := db.PostByID(postID)
  1411  		if post == nil || !post.IsOekaki() {
  1412  			data.BoardError(w, "invalid or deleted post")
  1413  			return
  1414  		}
  1415  
  1416  		data := s.buildData(db, w, r)
  1417  		data.Template = "oekaki"
  1418  		data.Message2 = template.HTML(`
  1419  		<script type="text/javascript">
  1420  		Tegaki.open({
  1421  			width: ` + strconv.Itoa(s.opt.OekakiWidth) + `,
  1422  			height: ` + strconv.Itoa(s.opt.OekakiHeight) + `,
  1423  			replayMode: true,
  1424  			replayURL: '` + post.Board.Path() + `src/` + post.File + `'
  1425  		});
  1426  		document.getElementById('tegaki-finish-btn').addEventListener('click', function(e) {
  1427  			window.close();
  1428  			return false;
  1429  		});
  1430  		</script>`)
  1431  		data.execute(w)
  1432  		return
  1433  	}
  1434  
  1435  	if data.Account != nil {
  1436  		db.UpdateAccountLastActive(data.Account.ID)
  1437  	}
  1438  
  1439  	data.Template = "manage_login"
  1440  
  1441  	if data.Account == nil {
  1442  		data.execute(w)
  1443  		return
  1444  	} else if s.config.ImportMode {
  1445  		if data.Account.Role != RoleSuperAdmin {
  1446  			data.ManageError("Sriracha is running in import mode. Only super-administrators may log in.")
  1447  			data.execute(w)
  1448  			return
  1449  		} else if !strings.HasPrefix(r.URL.Path, "/sriracha/import/") {
  1450  			http.Redirect(w, r, "/sriracha/import/", http.StatusFound)
  1451  			return
  1452  		}
  1453  		data.Info = "IMPORT MODE"
  1454  	}
  1455  
  1456  	switch {
  1457  	case strings.HasPrefix(r.URL.Path, "/sriracha/preference"):
  1458  		s.servePreference(data, db, w, r)
  1459  	case strings.HasPrefix(r.URL.Path, "/sriracha/account"):
  1460  		s.serveAccount(data, db, w, r)
  1461  	case strings.HasPrefix(r.URL.Path, "/sriracha/banner"):
  1462  		s.serveBanner(data, db, w, r)
  1463  	case strings.HasPrefix(r.URL.Path, "/sriracha/ban"):
  1464  		s.serveBan(data, db, w, r)
  1465  	case strings.HasPrefix(r.URL.Path, "/sriracha/board"):
  1466  		skipExecute = s.serveBoard(data, db, w, r)
  1467  	case strings.HasPrefix(r.URL.Path, "/sriracha/import"):
  1468  		s.serveImport(data, db, w, r)
  1469  	case strings.HasPrefix(r.URL.Path, "/sriracha/keyword"):
  1470  		s.serveKeyword(data, db, w, r)
  1471  	case strings.HasPrefix(r.URL.Path, "/sriracha/log"):
  1472  		s.serveLog(data, db, w, r)
  1473  	case strings.HasPrefix(r.URL.Path, "/sriracha/mod"):
  1474  		s.serveMod(data, db, w, r)
  1475  	case strings.HasPrefix(r.URL.Path, "/sriracha/news"):
  1476  		s.serveNews(data, db, w, r)
  1477  	case strings.HasPrefix(r.URL.Path, "/sriracha/page"):
  1478  		s.servePage(data, db, w, r)
  1479  	case strings.HasPrefix(r.URL.Path, "/sriracha/plugin"):
  1480  		s.servePlugin(data, db, w, r)
  1481  	case strings.HasPrefix(r.URL.Path, "/sriracha/setting"):
  1482  		s.serveSetting(data, db, w, r)
  1483  	default:
  1484  		s.serveStatus(data, db, w, r)
  1485  	}
  1486  
  1487  	if skipExecute {
  1488  		return
  1489  	}
  1490  	data.execute(w)
  1491  }
  1492  
  1493  // serve serves web requests.
  1494  func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
  1495  	s.lock.Lock()
  1496  	defer s.lock.Unlock()
  1497  
  1498  	if r.Method == http.MethodPost {
  1499  		const maxMemory = 32 << 20 // 32 megabytes.
  1500  		r.ParseMultipartForm(maxMemory)
  1501  
  1502  		var modified bool
  1503  		f := make(url.Values)
  1504  		for key, values := range r.Form {
  1505  			f[key] = make([]string, len(values))
  1506  			for i := range values {
  1507  				modified = true
  1508  				f[key][i] = strings.ReplaceAll(values[i], "\r", "")
  1509  			}
  1510  		}
  1511  		if modified {
  1512  			r.Form = f
  1513  		}
  1514  	}
  1515  
  1516  	var action string
  1517  	if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
  1518  		action = r.FormValue("action")
  1519  		if action == "" {
  1520  			values := r.URL.Query()
  1521  			action = values.Get("action")
  1522  		}
  1523  	} else if strings.HasPrefix(r.URL.Path, "/sriracha/captcha/") {
  1524  		action = "captcha"
  1525  	}
  1526  
  1527  	db := s.begin()
  1528  	defer db.Commit()
  1529  	var handled bool
  1530  
  1531  	db.DeleteExpiredSubscriptions()
  1532  
  1533  	if db.DeleteExpiredBans() > 0 {
  1534  		s.reloadBans(db)
  1535  	}
  1536  
  1537  	// Check IP range ban.
  1538  	ip := s.requestIP(r)
  1539  	for ban, pattern := range s.rangeBans {
  1540  		if pattern.MatchString(ip) {
  1541  			data := s.buildData(db, w, r)
  1542  			data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
  1543  			data.execute(w)
  1544  			handled = true
  1545  			break
  1546  		}
  1547  	}
  1548  
  1549  	// Check static IP ban.
  1550  	if !handled {
  1551  		ban := db.BanByIP(s.hashIP(r))
  1552  		if ban != nil {
  1553  			data := s.buildData(db, w, r)
  1554  			data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
  1555  			data.execute(w)
  1556  			handled = true
  1557  		} else if strings.HasPrefix(r.URL.Path, "/sriracha/post/") {
  1558  			postID := PathInt(r, "/sriracha/post/")
  1559  			post := db.PostByID(postID)
  1560  			if post == nil {
  1561  				data := s.buildData(db, w, r)
  1562  				data.BoardError(w, "Invalid or deleted post.")
  1563  			} else {
  1564  				http.Redirect(w, r, fmt.Sprintf("%sres/%d.html#%d", post.Board.Path(), post.Thread(), post.ID), http.StatusFound)
  1565  			}
  1566  			handled = true
  1567  		}
  1568  	}
  1569  
  1570  	if !handled {
  1571  		if strings.HasPrefix(r.URL.Path, "/sriracha/subscribe") {
  1572  			action = "subscribe"
  1573  		}
  1574  
  1575  		if s.config.ImportMode && action != "" {
  1576  			data := s.buildData(db, w, r)
  1577  			data.BoardError(w, "Sriracha is running in import mode. All boards are currently locked. Please wait and try again.")
  1578  		} else {
  1579  			switch action {
  1580  			case "post":
  1581  				s.servePost(db, w, r)
  1582  			case "report":
  1583  				s.serveReport(db, w, r)
  1584  			case "delete":
  1585  				s.serveDelete(db, w, r)
  1586  			case "captcha":
  1587  				s.serveCAPTCHA(db, w, r)
  1588  			case "subscribe":
  1589  				s.serveSubscribe(db, w, r)
  1590  			default:
  1591  				s.serveManage(db, w, r)
  1592  			}
  1593  		}
  1594  	}
  1595  }
  1596  
  1597  // listen listens for HTTP connections.
  1598  func (s *Server) listen() error {
  1599  	info, err := os.Stat("static/css/futaba.css")
  1600  	if err != nil || info.IsDir() {
  1601  		return 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")
  1602  	}
  1603  
  1604  	mux := http.NewServeMux()
  1605  	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
  1606  	mux.HandleFunc("/sriracha/", s.serve)
  1607  	mux.Handle("/", http.FileServer(http.Dir(s.config.Root)))
  1608  
  1609  	fmt.Printf("Serving http://%s\n", s.config.Serve)
  1610  	s.httpServer = &http.Server{
  1611  		Addr:    s.config.Serve,
  1612  		Handler: mux,
  1613  	}
  1614  	return s.httpServer.ListenAndServe()
  1615  }
  1616  
  1617  // handleRebuild handles requests to rebuild threads.
  1618  func (s *Server) handleRebuild() {
  1619  	defer s.rebuildWaitGroup.Done()
  1620  
  1621  	minWait := 1 * time.Second
  1622  	maxWait := 10 * time.Second
  1623  
  1624  	lastBuild := time.Now()
  1625  
  1626  	var info *rebuildInfo
  1627  	var pending []*rebuildInfo
  1628  	var boards []*Board
  1629  	var threads []int
  1630  	var shutdown bool
  1631  	var t *time.Timer
  1632  	for {
  1633  		// Process queue.
  1634  		info = <-s.rebuildQueue
  1635  		if info == nil {
  1636  			return // Shut down.
  1637  		}
  1638  		pending = append(pending, info)
  1639  		if time.Since(lastBuild) < maxWait {
  1640  			for {
  1641  				// Drain queue until minimum wait time has passed.
  1642  				t = time.NewTimer(minWait)
  1643  				var found bool
  1644  			DRAINQUEUE:
  1645  				for {
  1646  					select {
  1647  					case info = <-s.rebuildQueue:
  1648  						if info == nil {
  1649  							shutdown = true
  1650  						} else {
  1651  							pending = append(pending, info)
  1652  							found = true
  1653  						}
  1654  					case <-t.C:
  1655  						break DRAINQUEUE
  1656  					}
  1657  				}
  1658  				if !found {
  1659  					break
  1660  				}
  1661  				// Check if maximum wait time has passed.
  1662  				if time.Since(lastBuild) >= maxWait {
  1663  					break
  1664  				}
  1665  			}
  1666  		}
  1667  		if shutdown && len(pending) == 0 {
  1668  			return
  1669  		}
  1670  
  1671  		// Flush queue.
  1672  		db := s.begin()
  1673  		for _, info := range pending {
  1674  			thread := info.post.Thread()
  1675  			if !slices.Contains(threads, thread) {
  1676  				s.writeThread(db, info.post.Board, thread)
  1677  				threads = append(threads, thread)
  1678  			}
  1679  			if !slices.Contains(boards, info.post.Board) {
  1680  				s.writeIndexes(db, info.post.Board)
  1681  				boards = append(boards, info.post.Board)
  1682  			}
  1683  		}
  1684  		if s.opt.Overboard != "" {
  1685  			s.writeOverboard(db)
  1686  		}
  1687  		for _, info := range pending {
  1688  			s.queueNotifications(db, info.post)
  1689  		}
  1690  		db.Commit()
  1691  
  1692  		for _, info := range pending {
  1693  			info.wg.Done()
  1694  		}
  1695  
  1696  		pending = pending[:0]
  1697  		boards = boards[:0]
  1698  		threads = threads[:0]
  1699  
  1700  		lastBuild = time.Now()
  1701  
  1702  		if shutdown {
  1703  			return
  1704  		}
  1705  	}
  1706  }
  1707  
  1708  func (s *Server) _handleSignal(signals chan os.Signal) {
  1709  	// Wait until SIGINT or SIGTERM is received.
  1710  	<-signals
  1711  	// Shut down server.
  1712  	s.Stop()
  1713  }
  1714  
  1715  // startSignalHandler starts the signal handler which is responsible for
  1716  // shutting down the server when SIGINT or SIGTERM is received.
  1717  func (s *Server) startSignalHandler() {
  1718  	signals := make(chan os.Signal, 1)
  1719  	signal.Notify(signals, unix.SIGINT, unix.SIGTERM)
  1720  	go s._handleSignal(signals)
  1721  }
  1722  
  1723  // Run initializes the server and starts listening for connections.
  1724  func (s *Server) Run() error {
  1725  	s.parseBuildInfo()
  1726  
  1727  	printInfo := func() {
  1728  		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")
  1729  	}
  1730  	flag.Usage = func() {
  1731  		fmt.Fprintf(os.Stderr, "Usage:\n  sriracha [OPTION...] [PLUGIN...]\n\nOptions:\n")
  1732  		flag.PrintDefaults()
  1733  		printInfo()
  1734  	}
  1735  	var (
  1736  		configFile   string
  1737  		devMode      bool
  1738  		rebuild      bool
  1739  		printVersion bool
  1740  	)
  1741  	flag.StringVar(&configFile, "config", "", "path to configuration file (default: ~/.config/sriracha/config.yml)")
  1742  	flag.BoolVar(&devMode, "dev", false, "run in development mode (watch official and custom template files for changes)")
  1743  	flag.BoolVar(&rebuild, "rebuild", false, "rebuild static files before serving any requests")
  1744  	flag.BoolVar(&printVersion, "version", false, "print version information and exit")
  1745  	flag.Parse()
  1746  
  1747  	if printVersion {
  1748  		fmt.Fprintf(os.Stderr, "Sriracha version %s\n", SrirachaVersion)
  1749  		printInfo()
  1750  		return nil
  1751  	}
  1752  
  1753  	s.rebuildWaitGroup.Add(1)
  1754  	go s.handleRebuild()
  1755  
  1756  	s.startSignalHandler()
  1757  
  1758  	if configFile == "" {
  1759  		homeDir, err := os.UserHomeDir()
  1760  		if err == nil {
  1761  			configFile = path.Join(homeDir, ".config", "sriracha", "config.yml")
  1762  		}
  1763  	}
  1764  
  1765  	err := s.parseConfig(configFile)
  1766  	if err != nil {
  1767  		return fmt.Errorf("failed to parse configuration %s: %s", configFile, err)
  1768  	}
  1769  	s.config.StartTime = time.Now()
  1770  
  1771  	// Set default gettext domain.
  1772  	gotext.SetDomain("sriracha")
  1773  
  1774  	err = s.parseLocales()
  1775  	if err != nil {
  1776  		log.Fatalf("failed to parse locale files: %s", err)
  1777  	}
  1778  
  1779  	var officialDir string
  1780  	if devMode {
  1781  		s.opt.DevMode = true
  1782  
  1783  		officialDir = s.officialTemplateDir()
  1784  		if officialDir == "" {
  1785  			return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
  1786  		}
  1787  
  1788  		err = s.validateTemplateConfig(officialDir)
  1789  		if err != nil {
  1790  			return fmt.Errorf("invalid custom template directory: %s", err)
  1791  		}
  1792  	}
  1793  
  1794  	if s.config.MailAddress != "" {
  1795  		s.opt.Notifications = true
  1796  		if !devMode {
  1797  			fmt.Println("Verifying mail server configuration...")
  1798  			client, err := s.connectToMailServer()
  1799  			if err != nil {
  1800  				log.Fatalf("failed to verify mail server configuration: %s", err)
  1801  			}
  1802  			client.Close()
  1803  		}
  1804  	}
  1805  
  1806  	s.dbPool, err = database.Connect(s.config)
  1807  	if err != nil {
  1808  		return fmt.Errorf("failed to connect to database: %s", err)
  1809  	}
  1810  
  1811  	err = s.setDefaultServerConfig()
  1812  	if err != nil {
  1813  		return fmt.Errorf("failed to set default server configuration: %s", err)
  1814  	}
  1815  
  1816  	err = s.loadPluginConfig()
  1817  	if err != nil {
  1818  		return fmt.Errorf("failed to load plugin configuration: %s", err)
  1819  	}
  1820  
  1821  	err = s.loadPlugins()
  1822  	if err != nil {
  1823  		return fmt.Errorf("failed to load plugins: %s", err)
  1824  	}
  1825  
  1826  	err = s.setDefaultPluginConfig()
  1827  	if err != nil {
  1828  		return fmt.Errorf("failed to set default plugin configuration: %s", err)
  1829  	}
  1830  
  1831  	err = s.parseTemplates(officialDir, s.config.Template)
  1832  	if err != nil {
  1833  		return fmt.Errorf("failed to parse template files: %s", err)
  1834  	}
  1835  
  1836  	if unix.Access(s.config.Root, unix.W_OK) != nil {
  1837  		return fmt.Errorf("configured root directory %s is not writable", s.config.Root)
  1838  	}
  1839  
  1840  	captchaDir := filepath.Join(s.config.Root, "captcha")
  1841  	_, err = os.Stat(captchaDir)
  1842  	if os.IsNotExist(err) {
  1843  		err := os.Mkdir(captchaDir, NewDirPermission)
  1844  		if err != nil {
  1845  			log.Fatalf("failed to create captcha directory: %s", err)
  1846  		}
  1847  	}
  1848  
  1849  	bannerDir := filepath.Join(s.config.Root, "banner")
  1850  	_, err = os.Stat(bannerDir)
  1851  	if os.IsNotExist(err) {
  1852  		err := os.Mkdir(bannerDir, NewDirPermission)
  1853  		if err != nil {
  1854  			log.Fatalf("failed to create banner directory: %s", err)
  1855  		}
  1856  	}
  1857  
  1858  	siteIndexFile := filepath.Join(s.config.Root, "index.html")
  1859  	_, err = os.Stat(siteIndexFile)
  1860  	if os.IsNotExist(err) {
  1861  		err = os.WriteFile(siteIndexFile, siteIndexHTML, NewFilePermission)
  1862  		if err != nil {
  1863  			log.Fatalf("failed to write site index at %s: %s", siteIndexFile, err)
  1864  		}
  1865  	}
  1866  
  1867  	// Rebuild everything on startup when explicitly requested and after upgrading.
  1868  	db := s.begin()
  1869  	s.refreshBannerCache(db)
  1870  	sv := db.GetString("sv") // Sriracha version.
  1871  	if sv != SrirachaVersion {
  1872  		if sv != "" {
  1873  			fmt.Printf("Upgraded from Sriracha version %s to %s, rebuilding...\n", sv, SrirachaVersion)
  1874  			rebuild = true
  1875  		}
  1876  		db.SaveString("sv", SrirachaVersion)
  1877  	}
  1878  	if rebuild {
  1879  		allPages := db.AllPages()
  1880  		if len(allPages) > 0 {
  1881  			fmt.Println("Rebuilding pages...")
  1882  			s.writePages(db, allPages, false)
  1883  		}
  1884  		published := len(db.AllNews(true))
  1885  		if published > 0 {
  1886  			fmt.Println("Rebuilding news...")
  1887  			s.rebuildNews(db)
  1888  		}
  1889  		if s.opt.Overboard != "" {
  1890  			fmt.Println("Rebuilding overboard...")
  1891  			s.writeOverboard(db)
  1892  		}
  1893  		for _, b := range db.AllBoards() {
  1894  			fmt.Printf("Rebuilding %s...\n", b.Path())
  1895  			s.rebuildBoard(db, b)
  1896  		}
  1897  	}
  1898  	db.Commit()
  1899  
  1900  	if devMode {
  1901  		dir := "directory"
  1902  		if s.config.Template != "" {
  1903  			dir = "directories"
  1904  		}
  1905  		fmt.Printf("Development mode enabled. Monitoring template %s...\n", dir)
  1906  		err = s.watchTemplates(officialDir)
  1907  		if err != nil {
  1908  			return fmt.Errorf("failed to watch templates for changes: %s", err)
  1909  		}
  1910  	}
  1911  
  1912  	if s.config.MailAddress != "" {
  1913  		s.notificationsWaitGroup.Add(1)
  1914  		go s.handleNotifications()
  1915  	}
  1916  
  1917  	err = s.listen()
  1918  	if err != nil {
  1919  		if err != http.ErrServerClosed {
  1920  			return err
  1921  		}
  1922  		// Wait until all web requests have been processed.
  1923  		s.rebuildWaitGroup.Wait()
  1924  		// Wait until all notifications have been sent.
  1925  		s.notificationsWaitGroup.Wait()
  1926  	}
  1927  	return nil
  1928  }
  1929  
  1930  // hashData returns the salted hash of the provided data.
  1931  func (s *Server) hashData(data string) string {
  1932  	checksum := sha512.Sum384([]byte(data + s.config.SaltData))
  1933  	return base64.URLEncoding.EncodeToString(checksum[:])
  1934  }
  1935  
  1936  // md5Sum returns the MD5 sum of the provided data.
  1937  func md5Sum(data string) string {
  1938  	return fmt.Sprintf("%x", md5.Sum([]byte(data)))
  1939  }
  1940  
  1941  // parseHostname returns the hostname portion of an address.
  1942  func parseHostname(address string) string {
  1943  	if address == "" {
  1944  		return ""
  1945  	}
  1946  	hostname, port, err := net.SplitHostPort(address)
  1947  	if err != nil || port == "" {
  1948  		return address
  1949  	}
  1950  	return hostname
  1951  }
  1952  
  1953  // requestIP returns the remote IP address of a request.
  1954  func (s *Server) requestIP(r *http.Request) string {
  1955  	var address string
  1956  	if s.config.Header != "" {
  1957  		values := r.Header[s.config.Header]
  1958  		if len(values) > 0 {
  1959  			address = values[0]
  1960  		}
  1961  	} else {
  1962  		address = r.RemoteAddr
  1963  	}
  1964  	if address == "" {
  1965  		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.")
  1966  	}
  1967  	return parseHostname(address)
  1968  }
  1969  
  1970  func (s *Server) _hashIP(address string) string {
  1971  	if address == "" {
  1972  		return ""
  1973  	}
  1974  	return s.hashData(parseHostname(address))
  1975  }
  1976  
  1977  // hashIP returns the salted hash of a request's IP address.
  1978  func (s *Server) hashIP(r *http.Request) string {
  1979  	return s._hashIP(s.requestIP(r))
  1980  }
  1981  
  1982  // imageDimensions returns the width and height of a JPG, PNG or GIF image.
  1983  func (s *Server) imageDimensions(buf []byte) (int, int) {
  1984  	imgConfig, _, err := image.DecodeConfig(bytes.NewReader(buf))
  1985  	if err != nil {
  1986  		return 0, 0
  1987  	}
  1988  	return imgConfig.Width, imgConfig.Height
  1989  }
  1990  
  1991  // Stop shuts down the server gracefully.
  1992  func (s *Server) Stop() {
  1993  	fmt.Println("Shutting down...")
  1994  
  1995  	// Stop serving new web requests.
  1996  	if s.httpServer != nil {
  1997  		s.httpServer.Shutdown(context.Background())
  1998  	}
  1999  
  2000  	// Wait until existing web requests finish processing.
  2001  	s.lock.Lock()
  2002  	s.rebuildLock.Lock()
  2003  	s.rebuildQueue <- nil
  2004  	s.rebuildWaitGroup.Wait()
  2005  
  2006  	// Flush notification queue.
  2007  	if s.opt.Notifications {
  2008  		s.shutdownNotifications <- struct{}{}
  2009  	}
  2010  
  2011  	// If the HTTP server hasn't started yet, exit immediately.
  2012  	if s.httpServer == nil {
  2013  		os.Exit(0)
  2014  	}
  2015  }
  2016  
  2017  // pluginByName returns the specified plugin instance and associated plugin information.
  2018  func pluginByName(name string) (any, *pluginInfo) {
  2019  	name = strings.ToLower(name)
  2020  	for i, info := range allPluginInfo {
  2021  		if strings.ToLower(info.Name) == name {
  2022  			return allPlugins[i], info
  2023  		}
  2024  	}
  2025  	return nil, nil
  2026  }
  2027  
  2028  // FormatValue formats a value as a human-readable string.
  2029  func FormatValue(v interface{}) interface{} {
  2030  	if role, ok := v.(AccountRole); ok {
  2031  		return FormatRole(role)
  2032  	} else if t, ok := v.(BoardType); ok {
  2033  		return FormatBoardType(t)
  2034  	} else if t, ok := v.(BoardHide); ok {
  2035  		return FormatBoardHide(t)
  2036  	} else if t, ok := v.(BoardLock); ok {
  2037  		return FormatBoardLock(t)
  2038  	} else if t, ok := v.(BoardApproval); ok {
  2039  		return FormatBoardApproval(t)
  2040  	} else if t, ok := v.(BoardIdentifiers); ok {
  2041  		return FormatBoardIdentifiers(t)
  2042  	}
  2043  	return v
  2044  }
  2045  
  2046  // printChanges returns the difference between two structs as a human-readable string.
  2047  func printChanges(old interface{}, new interface{}) string {
  2048  	const mask = "***"
  2049  	diff, err := diff.Diff(old, new)
  2050  	if err != nil {
  2051  		log.Fatal(err)
  2052  	} else if len(diff) == 0 {
  2053  		return ""
  2054  	}
  2055  	var label string
  2056  	for _, change := range diff {
  2057  		from := change.From
  2058  		to := change.To
  2059  
  2060  		var name string
  2061  		if len(change.Path) > 0 {
  2062  			name = change.Path[0]
  2063  			if name == "Password" {
  2064  				from = mask
  2065  				to = mask
  2066  			}
  2067  		}
  2068  
  2069  		label += fmt.Sprintf(` [%s: "%v" > "%v"]`, name, FormatValue(from), FormatValue(to))
  2070  	}
  2071  	return label
  2072  }
  2073  
  2074  // calculateFileHash returns the unsalted hash of the provided data.
  2075  func calculateFileHash(buf []byte) string {
  2076  	checksum := sha512.Sum384(buf)
  2077  	return base64.URLEncoding.EncodeToString(checksum[:])
  2078  }
  2079  
  2080  // pageCount returns the number of pages required to display the provided number of items.
  2081  func pageCount(items int, pageSize int) int {
  2082  	if items == 0 || pageSize == 0 {
  2083  		return 1
  2084  	}
  2085  	pages := items / pageSize
  2086  	if items%pageSize != 0 {
  2087  		pages++
  2088  	}
  2089  	return pages
  2090  }
  2091  
  2092  // doctypePrefx is an HTML prefix which may be used in custom pages to skip
  2093  // including the default page header and footer templates.
  2094  const doctypePrefx = "<!DOCTYPE html>"
  2095  
  2096  // siteIndexHTML is an HTML page written to index.html when such a file does not already exist.
  2097  var siteIndexHTML = []byte(`
  2098  <!DOCTYPE html>
  2099  <html>
  2100  	<body>
  2101  		<meta http-equiv="refresh" content="0; url=/sriracha/">
  2102  		<a href="/sriracha/">Redirecting...</a>
  2103  	</body>
  2104  </html>
  2105  `)
  2106  

View as plain text