1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-20 09:26:23 -07:00

Make config loading more flexible and powerful

Improve value validation, allow for defaults and set critical fields
This commit is contained in:
Eggbertx 2021-03-02 17:42:07 -08:00
parent 6bd77b7c34
commit cb7913398c
10 changed files with 630 additions and 227 deletions

2
go.mod
View file

@ -15,5 +15,5 @@ require (
github.com/tdewolff/parse v2.3.4+incompatible // indirect
github.com/tdewolff/test v1.0.6 // indirect
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
golang.org/x/net v0.0.0-20210222171744-9060382bd457
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
)

2
go.sum
View file

@ -59,6 +59,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210222171744-9060382bd457 h1:hMm9lBjyNLe/c9C6bElQxp4wsrleaJn1vXMZIQkNN44=
golang.org/x/net v0.0.0-20210222171744-9060382bd457/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View file

@ -2,19 +2,68 @@ package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
"path"
"time"
"net"
"reflect"
"github.com/gochan-org/gochan/pkg/gclog"
)
const (
randomStringSize = 16
)
var (
Config GochanConfig
cfgPath string
Config *GochanConfig
cfgPath string
cfgDefaults = map[string]interface{}{
"Port": 8080,
"FirstPage": []string{"index.html", "board.html", "firstrun.html"},
"DocumentRoot": "html",
"TemplateDir": "templates",
"LogDir": "log",
"SillyTags": []string{},
"SiteName": "Gochan",
"SiteWebFolder": "/",
"NewThreadDelay": 30,
"ReplyDelay": 7,
"MaxLineLength": 150,
"ThreadsPerPage": 15,
"RepliesOnBoardPage": 3,
"StickyRepliesOnBoardPage": 1,
"ThumbWidth": 200,
"ThumbHeight": 200,
"ThumbWidthReply": 125,
"ThumbHeightReply": 125,
"ThumbWidthCatalog": 50,
"ThumbHeightCatalog": 50,
"BanMsg": "USER WAS BANNED FOR THIS POST",
"EmbedWidth": 200,
"EmbedHeight": 164,
"ExpandButton": true,
"NewTabOnOutlinks": true,
"MinifyHTML": true,
"MinifyJS": true,
"CaptchaWidth": 240,
"CaptchaHeight": 80,
"DateTimeFormat": "Mon, January 02, 2006 15:04 PM",
"CaptchaMinutesExpire": 15,
"EnableGeoIP": true,
"GeoIPDBlocation": "/usr/share/GeoIP/GeoIP.dat",
"MaxRecentPosts": 3,
"MaxLogDays": 15,
}
)
// Style represents a theme (Pipes, Dark, etc)
@ -23,87 +72,102 @@ type Style struct {
Filename string
}
// GochanConfig stores crucial info and is read from/written to gochan.json
// GochanConfig stores important info and is read from/written to gochan.json.
// If a field has an entry in the defaults map, that value will be used here.
// If a field has a critical struct tag set to "true", a warning will be printed
// if it exists in the defaults map and an error will be printed if it doesn't.
type GochanConfig struct {
ListenIP string
Port int
FirstPage []string
Username string
UseFastCGI bool
DebugMode bool `description:"Disables several spam/browser checks that can cause problems when hosting an instance locally."`
ListenIP string `critical:"true"`
Port int `critical:"true"`
FirstPage []string `critical:"true"`
Username string `critical:"true"`
UseFastCGI bool `critical:"true"`
DebugMode bool `description:"Disables several spam/browser checks that can cause problems when hosting an instance locally."`
DocumentRoot string
TemplateDir string
LogDir string
DocumentRoot string `critical:"true"`
TemplateDir string `critical:"true"`
LogDir string `critical:"true"`
DBtype string
DBhost string
DBname string
DBusername string
DBpassword string
DBprefix string
DBtype string `critical:"true"`
DBhost string `critical:"true"`
DBname string `critical:"true"`
DBusername string `critical:"true"`
DBpassword string `critical:"true"`
DBprefix string `description:"Each table's name in the database will start with this, if it is set"`
Lockdown bool `description:"Disables posting." default:"unchecked"`
LockdownMessage string `description:"Message displayed when someone tries to post while the site is on lockdown."`
Sillytags []string `description:"List of randomly selected staff tags separated by line, e.g. <span style=\"color: red;\">## Mod</span>, to be randomly assigned to posts if UseSillytags is checked. Don't include the \"## \""`
UseSillytags bool `description:"Use Sillytags" default:"unchecked"`
Modboard string `description:"A super secret clubhouse board that only staff can view/post to." default:"staff"`
SiteName string `description:"The name of the site that appears in the header of the front page." default:"Gochan"`
SiteName string `description:"The name of the site that appears in the header of the front page."`
SiteSlogan string `description:"The text that appears below SiteName on the home page"`
SiteHeaderURL string `description:"To be honest, I'm not even sure what this does. It'll probably be removed later."`
SiteWebfolder string `description:"The HTTP root appearing in the browser (e.g. https://gochan.org/&lt;SiteWebFolder&gt;" default:"/"`
SiteDomain string `description:"The server's domain (duh). Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"127.0.0.1" critical:"true"`
SiteWebfolder string `critical:"true" description:"The HTTP root appearing in the browser (e.g. https://gochan.org/<SiteWebFolder>"`
SiteDomain string `critical:"true" description:"The server's domain. Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!"`
Styles []Style `description:"List of styles (one per line) that should be accessed online at &lt;SiteWebFolder&gt;/css/&lt;Style&gt;/"`
DefaultStyle string `description:"Filename of the default Style. This should appear in the list above or bad things might happen."`
Lockdown bool `description:"Disables posting."`
LockdownMessage string `description:"Message displayed when someone tries to post while the site is on lockdown."`
Sillytags []string `description:"List of randomly selected fake staff tags separated by line, e.g. ## Mod, to be randomly assigned to posts if UseSillytags is checked. Don't include the \"## \""`
UseSillytags bool `description:"Use Sillytags"`
Modboard string `description:"A super secret clubhouse board that only staff can view/post to."`
AllowDuplicateImages bool `description:"Disabling this will cause gochan to reject a post if the image has already been uploaded for another post.<br />This may end up being removed or being made board-specific in the future." default:"checked"`
AllowVideoUploads bool `description:"Allows users to upload .webm videos. <br />This may end up being removed or being made board-specific in the future."`
NewThreadDelay int `description:"The amount of time in seconds that is required before an IP can make a new thread.<br />This may end up being removed or being made board-specific in the future." default:"30"`
ReplyDelay int `description:"Same as the above, but for replies." default:"7"`
MaxLineLength int `description:"Any line in a post that exceeds this will be split into two (or more) lines.<br />I'm not really sure why this is here, so it may end up being removed." default:"150"`
ReservedTrips []string `description:"Secure tripcodes (!!Something) can be reserved here.<br />Each reservation should go on its own line and should look like this:<br />TripPassword1##Tripcode1<br />TripPassword2##Tripcode2"`
Styles []Style `critical:"true" description:"List of styles (one per line) that should be accessed online at <SiteWebFolder>/css/<Style>"`
DefaultStyle string `description:"Filename of the default Style. If this unset, the first entry in the Styles array will be used."`
ThumbWidth int `description:"OP thumbnails use this as their max width.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbHeight int `description:"OP thumbnails use this as their max height.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbWidthReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbHeightReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbWidthCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
ThumbHeightCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
RejectDuplicateImages bool `description:"Enabling this will cause gochan to reject a post if the image has already been uploaded for another post.\nThis may end up being removed or being made board-specific in the future."`
NewThreadDelay int `description:"The amount of time in seconds that is required before an IP can make a new thread.<br />This may end up being removed or being made board-specific in the future."`
ReplyDelay int `description:"Same as the above, but for replies."`
MaxLineLength int `description:"Any line in a post that exceeds this will be split into two (or more) lines.<br />I'm not really sure why this is here, so it may end up being removed."`
ReservedTrips []string `description:"Secure tripcodes (!!Something) can be reserved here.<br />Each reservation should go on its own line and should look like this:<br />TripPassword1##Tripcode1<br />TripPassword2##Tripcode2"`
ThreadsPerPage int `default:"15"`
RepliesOnBoardPage int `description:"Number of replies to a thread to show on the board page." default:"3"`
StickyRepliesOnBoardPage int `description:"Same as above for stickied threads." default:"1"`
BanColors []string `description:"Colors to be used for public ban messages (e.g. USER WAS BANNED FOR THIS POST).<br />Each entry should be on its own line, and should look something like this:<br />username1:#FF0000<br />username2:#FAF00F<br />username3:blue<br />Invalid entries/nonexistent usernames will show a warning and use the default red."`
BanMsg string `description:"The default public ban message." default:"USER WAS BANNED FOR THIS POST"`
EmbedWidth int `description:"The width for inline/expanded webm videos." default:"200"`
EmbedHeight int `description:"The height for inline/expanded webm videos." default:"164"`
ExpandButton bool `description:"If checked, adds [Embed] after a Youtube, Vimeo, etc link to toggle an inline video frame." default:"checked"`
ImagesOpenNewTab bool `description:"If checked, thumbnails will open the respective image/video in a new tab instead of expanding them." default:"unchecked"`
MakeURLsHyperlinked bool `description:"If checked, URLs in posts will be turned into a hyperlink. If unchecked, ExpandButton and NewTabOnOutlinks are ignored." default:"checked"`
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab." default:"checked"`
DisableBBcode bool `description:"If checked, gochan will not compile bbcode into HTML" default:"unchecked"`
ThumbWidth int `description:"OP thumbnails use this as their max width.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger."`
ThumbHeight int `description:"OP thumbnails use this as their max height.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger."`
ThumbWidthReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images."`
ThumbHeightReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images."`
ThumbWidthCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images."`
ThumbHeightCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images."`
MinifyHTML bool `description:"If checked, gochan will minify html files when building" default:"checked"`
MinifyJS bool `description:"If checked, gochan will minify js and json files when building" default:"checked"`
ThreadsPerPage int
RepliesOnBoardPage int `description:"Number of replies to a thread to show on the board page."`
StickyRepliesOnBoardPage int `description:"Same as above for stickied threads."`
BanMsg string `description:"The default public ban message."`
EmbedWidth int `description:"The width for inline/expanded webm videos."`
EmbedHeight int `description:"The height for inline/expanded webm videos."`
ExpandButton bool `description:"If checked, adds [Embed] after a Youtube, Vimeo, etc link to toggle an inline video frame."`
ImagesOpenNewTab bool `description:"If checked, thumbnails will open the respective image/video in a new tab instead of expanding them." `
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab."`
DisableBBcode bool `description:"If checked, gochan will not compile bbcode into HTML"`
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info." default:"Mon, January 02, 2006 15:04 PM"`
MinifyHTML bool `description:"If checked, gochan will minify html files when building"`
MinifyJS bool `description:"If checked, gochan will minify js and json files when building"`
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info."`
AkismetAPIKey string `description:"The API key to be sent to Akismet for post spam checking. If the key is invalid, Akismet won't be used."`
UseCaptcha bool `description:"If checked, a captcha will be generated"`
CaptchaWidth int `description:"Width of the generated captcha image" default:"240"`
CaptchaHeight int `description:"Height of the generated captcha image" default:"80"`
CaptchaMinutesExpire int `description:"Number of minutes before a user has to enter a new CAPTCHA before posting. If <1 they have to submit one for every post." default:"15"`
EnableGeoIP bool `description:"If checked, this enables the usage of GeoIP for posts." default:"checked"`
GeoIPDBlocation string `description:"Specifies the location of the GeoIP database file. If you're using CloudFlare, you can set it to cf to rely on CloudFlare for GeoIP information." default:"/usr/share/GeoIP/GeoIP.dat"`
MaxRecentPosts int `description:"The maximum number of posts to show on the Recent Posts list on the front page." default:"3"`
RecentPostsWithNoFile bool `description:"If checked, recent posts with no image/upload are shown on the front page (as well as those with images" default:"unchecked"`
EnableAppeals bool `description:"If checked, allow banned users to appeal their bans.<br />This will likely be removed (permanently allowing appeals) or made board-specific in the future." default:"checked"`
CaptchaWidth int `description:"Width of the generated captcha image"`
CaptchaHeight int `description:"Height of the generated captcha image"`
CaptchaMinutesExpire int `description:"Number of minutes before a user has to enter a new CAPTCHA before posting. If <1 they have to submit one for every post."`
EnableGeoIP bool `description:"If checked, this enables the usage of GeoIP for posts."`
GeoIPDBlocation string `description:"Specifies the location of the GeoIP database file. If you're using CloudFlare, you can set it to cf to rely on CloudFlare for GeoIP information."`
MaxRecentPosts int `description:"The maximum number of posts to show on the Recent Posts list on the front page."`
RecentPostsWithNoFile bool `description:"If checked, recent posts with no image/upload are shown on the front page (as well as those with images"`
MaxLogDays int `description:"The maximum number of days to keep messages in the moderation/staff log file."`
RandomSeed string `critical:"true"`
TimeZone int `json:"-"`
Version *GochanVersion `json:"-"`
jsonLocation string `json:"-"`
TimeZone int `json:"-"`
Version *GochanVersion `json:"-"`
}
// ToMap returns the configuration file as a map
func (cfg *GochanConfig) ToMap() map[string]interface{} {
cVal := reflect.ValueOf(cfg).Elem()
cType := reflect.TypeOf(*cfg)
numFields := cType.NumField()
out := make(map[string]interface{})
for f := 0; f < numFields; f++ {
field := cVal.Field(f)
if !field.CanSet() {
continue
}
out[cType.Field(f).Name] = field.Elem().Interface()
}
return out
}
func (cfg *GochanConfig) checkString(val, defaultVal string, critical bool, msg string) string {
@ -134,152 +198,116 @@ func (cfg *GochanConfig) checkInt(val, defaultVal int, critical bool, msg string
return val
}
// InitConfig loads and parses gochan.json and verifies its contents
func InitConfig(versionStr string) {
cfgPath = findResource("gochan.json", "/etc/gochan/gochan.json")
if cfgPath == "" {
fmt.Println("gochan.json not found")
os.Exit(1)
// ValidateValues checks to make sure that the configuration options are usable
// (e.g., ListenIP is a valid IP address, Port isn't a negative number, etc)
func (cfg *GochanConfig) ValidateValues() error {
if net.ParseIP(cfg.ListenIP) == nil {
return &ErrInvalidValue{Field: "ListenIP", Value: cfg.ListenIP}
}
jfile, err := ioutil.ReadFile(cfgPath)
if err != nil {
fmt.Printf("Error reading %s: %s\n", cfgPath, err.Error())
os.Exit(1)
changed := false
if len(cfg.FirstPage) == 0 {
cfg.FirstPage = cfgDefaults["FirstPage"].([]string)
changed = true
}
if err = json.Unmarshal(jfile, &Config); err != nil {
fmt.Printf("Error parsing %s: %s\n", cfgPath, err.Error())
os.Exit(1)
if cfg.DBtype != "mysql" && cfg.DBtype != "postgresql" {
return &ErrInvalidValue{Field: "DBtype", Value: cfg.DBtype, Details: "currently supported values: mysql, postgresql"}
}
Config.LogDir = findResource(Config.LogDir, "log", "/var/log/gochan/")
if err = gclog.InitLogs(
path.Join(Config.LogDir, "access.log"),
path.Join(Config.LogDir, "error.log"),
path.Join(Config.LogDir, "staff.log"),
Config.DebugMode); err != nil {
fmt.Println(err.Error())
os.Exit(1)
if len(cfg.Styles) == 0 {
return &ErrInvalidValue{Field: "Styles", Value: cfg.Styles}
}
Config.checkString(Config.ListenIP, "", true,
"ListenIP not set in gochan.json, halting.")
if Config.Port == 0 {
Config.Port = 80
if cfg.DefaultStyle == "" {
cfg.DefaultStyle = cfg.Styles[0].Filename
changed = true
}
if len(Config.FirstPage) == 0 {
Config.FirstPage = []string{"index.html", "1.html", "firstrun.html"}
if cfg.NewThreadDelay == 0 {
cfg.NewThreadDelay = cfgDefaults["NewThreadDelay"].(int)
changed = true
}
Config.Username = Config.checkString(Config.Username, "gochan", false,
"Username not set in gochan.json, using 'gochan' as default")
Config.DocumentRoot = Config.checkString(Config.DocumentRoot, "gochan", true,
"DocumentRoot not set in gochan.json, halting.")
wd, wderr := os.Getwd()
if wderr == nil {
_, staterr := os.Stat(path.Join(wd, Config.DocumentRoot, "css"))
if staterr == nil {
Config.DocumentRoot = path.Join(wd, Config.DocumentRoot)
if cfg.ReplyDelay == 0 {
cfg.ReplyDelay = cfgDefaults["ReplyDelay"].(int)
changed = true
}
if cfg.MaxLineLength == 0 {
cfg.MaxLineLength = cfgDefaults["MaxLineLength"].(int)
changed = true
}
if cfg.ThumbWidth == 0 {
cfg.ThumbWidth = cfgDefaults["ThumbWidth"].(int)
changed = true
}
if cfg.ThumbHeight == 0 {
cfg.ThumbHeight = cfgDefaults["ThumbHeight"].(int)
changed = true
}
if cfg.ThumbWidthReply == 0 {
cfg.ThumbWidthReply = cfgDefaults["ThumbWidthReply"].(int)
changed = true
}
if cfg.ThumbHeightReply == 0 {
cfg.ThumbHeightReply = cfgDefaults["ThumbHeightReply"].(int)
changed = true
}
if cfg.ThumbWidthCatalog == 0 {
cfg.ThumbWidthCatalog = cfgDefaults["ThumbWidthCatalog"].(int)
changed = true
}
if cfg.ThumbHeightCatalog == 0 {
cfg.ThumbHeightCatalog = cfgDefaults["ThumbHeightCatalog"].(int)
changed = true
}
if cfg.ThreadsPerPage == 0 {
cfg.ThreadsPerPage = cfgDefaults["ThreadsPerPage"].(int)
changed = true
}
if cfg.RepliesOnBoardPage == 0 {
cfg.RepliesOnBoardPage = cfgDefaults["RepliesOnBoardPage"].(int)
changed = true
}
if cfg.StickyRepliesOnBoardPage == 0 {
cfg.StickyRepliesOnBoardPage = cfgDefaults["StickyRepliesOnBoardPage"].(int)
changed = true
}
if cfg.BanMsg == "" {
cfg.BanMsg = cfgDefaults["BanMsg"].(string)
changed = true
}
if cfg.DateTimeFormat == "" {
cfg.DateTimeFormat = cfgDefaults["DateTimeFormat"].(string)
changed = true
}
if cfg.CaptchaWidth == 0 {
cfg.CaptchaWidth = cfgDefaults["CaptchaWidth"].(int)
changed = true
}
if cfg.CaptchaHeight == 0 {
cfg.CaptchaHeight = cfgDefaults["CaptchaHeight"].(int)
changed = true
}
if cfg.EnableGeoIP {
if cfg.GeoIPDBlocation == "" {
return &ErrInvalidValue{Field: "GeoIPDBlocation", Value: "", Details: "GeoIPDBlocation must be set in gochan.json if EnableGeoIP is true"}
}
}
Config.TemplateDir = Config.checkString(
findResource(Config.TemplateDir, "templates", "/usr/local/share/gochan/templates/", "/usr/share/gochan/templates/"), "", true,
"TemplateDir not set in gochan.json or unable to locate template directory, halting.")
Config.checkString(Config.DBtype, "", true,
"DBtype not set in gochan.json, halting (currently supported values: mysql,postgresql)")
Config.checkString(Config.DBhost, "", true,
"DBhost not set in gochan.json, halting.")
Config.DBname = Config.checkString(Config.DBname, "gochan", false,
"DBname not set in gochan.json, setting to 'gochan'")
Config.checkString(Config.DBusername, "", true,
"DBusername not set in gochan, halting.")
Config.checkString(Config.DBpassword, "", true,
"DBpassword not set in gochan, halting.")
Config.LockdownMessage = Config.checkString(Config.LockdownMessage,
"The administrator has temporarily disabled posting. We apologize for the inconvenience", false, "")
Config.checkString(Config.SiteName, "", true,
"SiteName not set in gochan.json, halting.")
Config.checkString(Config.SiteDomain, "", true,
"SiteName not set in gochan.json, halting.")
if Config.SiteWebfolder == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "SiteWebFolder not set in gochan.json, using / as default.")
} else if string(Config.SiteWebfolder[0]) != "/" {
Config.SiteWebfolder = "/" + Config.SiteWebfolder
}
if Config.SiteWebfolder[len(Config.SiteWebfolder)-1:] != "/" {
Config.SiteWebfolder += "/"
if cfg.MaxLogDays == 0 {
cfg.MaxLogDays = cfgDefaults["MaxLogDays"].(int)
changed = true
}
if Config.Styles == nil {
gclog.Print(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal, "Styles not set in gochan.json, halting.")
if cfg.RandomSeed == "" {
cfg.RandomSeed = randomString(randomStringSize)
changed = true
}
Config.DefaultStyle = Config.checkString(Config.DefaultStyle, Config.Styles[0].Filename, false, "")
Config.NewThreadDelay = Config.checkInt(Config.NewThreadDelay, 30, false, "")
Config.ReplyDelay = Config.checkInt(Config.ReplyDelay, 7, false, "")
Config.MaxLineLength = Config.checkInt(Config.MaxLineLength, 150, false, "")
//ReservedTrips string //eventually this will be map[string]string
Config.ThumbWidth = Config.checkInt(Config.ThumbWidth, 200, false, "")
Config.ThumbHeight = Config.checkInt(Config.ThumbHeight, 200, false, "")
Config.ThumbWidthReply = Config.checkInt(Config.ThumbWidthReply, 125, false, "")
Config.ThumbHeightReply = Config.checkInt(Config.ThumbHeightReply, 125, false, "")
Config.ThumbWidthCatalog = Config.checkInt(Config.ThumbWidthCatalog, 50, false, "")
Config.ThumbHeightCatalog = Config.checkInt(Config.ThumbHeightCatalog, 50, false, "")
Config.ThreadsPerPage = Config.checkInt(Config.ThreadsPerPage, 10, false, "")
Config.RepliesOnBoardPage = Config.checkInt(Config.RepliesOnBoardPage, 3, false, "")
Config.StickyRepliesOnBoardPage = Config.checkInt(Config.StickyRepliesOnBoardPage, 1, false, "")
/*config.BanColors, err = c.GetString("threads", "ban_colors") //eventually this will be map[string] string
if err != nil {
config.BanColors = "admin:#CC0000"
}*/
Config.BanMsg = Config.checkString(Config.BanMsg, "(USER WAS BANNED FOR THIS POST)", false, "")
Config.DateTimeFormat = Config.checkString(Config.DateTimeFormat, "Mon, January 02, 2006 15:04 PM", false, "")
Config.CaptchaWidth = Config.checkInt(Config.CaptchaWidth, 240, false, "")
Config.CaptchaHeight = Config.checkInt(Config.CaptchaHeight, 80, false, "")
if Config.EnableGeoIP {
if Config.GeoIPDBlocation == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "GeoIPDBlocation not set in gochan.json, disabling EnableGeoIP")
Config.EnableGeoIP = false
}
if !changed {
return nil
}
if Config.MaxLogDays == 0 {
Config.MaxLogDays = 15
}
if Config.RandomSeed == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "RandomSeed not set in gochan.json, Generating a random one.")
for i := 0; i < 8; i++ {
num := rand.Intn(127-32) + 32
Config.RandomSeed += fmt.Sprintf("%c", num)
}
configJSON, _ := json.MarshalIndent(Config, "", "\t")
if err = ioutil.WriteFile(cfgPath, configJSON, 0777); err != nil {
gclog.Printf(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal, "Unable to write %s with randomly generated seed: %s", cfgPath, err.Error())
}
}
_, zoneOffset := time.Now().Zone()
Config.TimeZone = zoneOffset / 60 / 60
// msgfmtr.InitBBcode()
Config.Version = ParseVersion(versionStr)
Config.Version.Normalize()
return cfg.Write()
}
func (cfg *GochanConfig) Write() error {
str, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
return ioutil.WriteFile(cfg.jsonLocation, str, 0777)
}

73
pkg/config/config_test.go Normal file
View file

@ -0,0 +1,73 @@
package config
import (
"encoding/json"
"fmt"
"testing"
)
func TestBadTypes(t *testing.T) {
_, _, err := ParseJSON([]byte(badTypeJSON))
if err == nil {
t.Fatal(`"successfully" parsed JSON file with incorrect value type`)
}
_, ok := err.(*json.UnmarshalTypeError)
if !ok {
t.Fatal(err.Error())
}
}
func TestMissingRequired(t *testing.T) {
_, missing, err := ParseJSON([]byte(missingRequiredJSON))
if err != nil {
t.Fatal(err.Error())
}
if len(missing) == 0 {
t.Fatal("JSON string with deliberately missing fields passed validation (this shouldn't happen for this test)")
}
fieldsStr := "Missing fields:\n"
for _, field := range missing {
fieldsStr += fmt.Sprintf("field name: %s\ndescription: %s\ncritical: %t\n\n", field.Name, field.Description, field.Critical)
}
t.Log(fieldsStr)
}
func TestBareMinimumJSON(t *testing.T) {
_, missing, err := ParseJSON([]byte(bareMinimumJSON))
if err != nil {
t.Fatal(err.Error())
}
if len(missing) == 0 {
return
}
fieldsStr := "Missing fields:\n"
for _, field := range missing {
fieldsStr += fmt.Sprintf("field name: %s\ndescription: %s\ncritical: %t\n\n", field.Name, field.Description, field.Critical)
}
t.Fatal(fieldsStr)
}
func TestValidJSON(t *testing.T) {
_, missing, err := ParseJSON([]byte(validCfgJSON))
if err != nil {
t.Fatal(err.Error())
}
if len(missing) == 0 {
return
}
fieldsStr := "Missing fields:\n"
for _, field := range missing {
fieldsStr += fmt.Sprintf("field name: %s\ndescription: %s\ncritical: %t\n\n", field.Name, field.Description, field.Critical)
}
t.Fatal(fieldsStr)
}
func TestValidValues(t *testing.T) {
cfg, _, err := ParseJSON([]byte(bareMinimumJSON))
if err != nil {
t.Fatal(err.Error())
}
if err := cfg.ValidateValues(); err != nil {
t.Fatal(err)
}
}

123
pkg/config/jsonvars_test.go Normal file
View file

@ -0,0 +1,123 @@
package config
import "strings"
const (
// the bare minimum fields required to pass GochanConfig.validate.
// this doesn't mean that the values are valid, just that they exist
bareMinimumJSON = `{
"ListenIP": "127.0.0.1",
"Port": 8080,
"Username": "gochan",
"UseFastCGI": true,
"DBtype": "mysql",
"DBhost": "127.0.0.1:3306",
"DBname": "gochan",
"DBusername": "gochan",
"DBpassword": "",
"SiteDomain": "127.0.0.1",
"SiteWebfolder": "/",
"Styles": [
{ "Name": "Pipes", "Filename": "pipes.css" },
{ "Name": "Burichan", "Filename": "burichan.css" },
{ "Name": "Dark", "Filename": "dark.css" },
{ "Name": "Photon", "Filename": "photon.css" }
],
"RandomSeed": "jeiwohaeiogpehwgui"
}`
validCfgJSON = `{
"ListenIP": "127.0.0.1",
"Port": 8080,
"FirstPage": ["index.html","firstrun.html","1.html"],
"Username": "gochan",
"UseFastCGI": false,
"DebugMode": false,
"DocumentRoot": "html",
"TemplateDir": "templates",
"LogDir": "log",
"DBtype": "mysql",
"DBtype_alt": "postgres",
"DBhost": "127.0.0.1:3306",
"_comment": "gochan can use either a URL or a UNIX socket for MySQL connections",
"DBhost_alt": "unix(/var/run/mysqld/mysqld.sock)",
"DBname": "gochan",
"DBusername": "gochan",
"DBpassword": "",
"DBprefix": "gc_",
"Lockdown": false,
"LockdownMessage": "This imageboard has temporarily disabled posting. We apologize for the inconvenience",
"Sillytags": ["Admin","Mod","Janitor","Faget","Kick me","Derpy","Troll","worst pony"],
"UseSillytags": false,
"Modboard": "staff",
"SiteName": "Gochan",
"SiteSlogan": "",
"SiteDomain": "127.0.0.1",
"SiteWebfolder": "/",
"Styles": [
{ "Name": "Pipes", "Filename": "pipes.css" },
{ "Name": "Burichan", "Filename": "burichan.css" },
{ "Name": "Dark", "Filename": "dark.css" },
{ "Name": "Photon", "Filename": "photon.css" }
],
"DefaultStyle": "pipes.css",
"RejectDuplicateImages": true,
"NewThreadDelay": 30,
"ReplyDelay": 7,
"MaxLineLength": 150,
"ReservedTrips": [
"thischangesto##this",
"andthischangesto##this"
],
"ThumbWidth": 200,
"ThumbHeight": 200,
"ThumbWidthReply": 125,
"ThumbHeightReply": 125,
"ThumbWidthCatalog": 50,
"ThumbHeightCatalog": 50,
"ThreadsPerPage": 15,
"PostsPerThreadPage": 50,
"RepliesOnBoardPage": 3,
"StickyRepliesOnBoardPage": 1,
"BanMsg": "USER WAS BANNED FOR THIS POST",
"EmbedWidth": 200,
"EmbedHeight": 164,
"ExpandButton": true,
"ImagesOpenNewTab": true,
"MakeURLsHyperlinked": true,
"NewTabOnOutlinks": true,
"MinifyHTML": true,
"MinifyJS": true,
"DateTimeFormat": "Mon, January 02, 2006 15:04 PM",
"AkismetAPIKey": "",
"UseCaptcha": false,
"CaptchaWidth": 240,
"CaptchaHeight": 80,
"CaptchaMinutesExpire": 15,
"EnableGeoIP": true,
"_comment": "set GeoIPDBlocation to cf to use Cloudflare's GeoIP",
"GeoIPDBlocation": "/usr/share/GeoIP/GeoIP.dat",
"MaxRecentPosts": 3,
"RecentPostsWithNoFile": false,
"Verbosity": 0,
"EnableAppeals": true,
"MaxLogDays": 14,
"_comment": "Set RandomSeed to a (preferrably large) string of letters and numbers",
"RandomSeed": ""
}`
)
var (
missingRequiredJSON = strings.ReplaceAll(validCfgJSON, `"ListenIP": "127.0.0.1",`, "")
badTypeJSON = strings.ReplaceAll(validCfgJSON, `"RandomSeed": ""`, `"RandomSeed": 32`)
)

View file

@ -1,6 +1,39 @@
package config
import "os"
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
"path"
"reflect"
"time"
"github.com/gochan-org/gochan/pkg/gclog"
)
// MissingField represents a field missing from the configuration file
type MissingField struct {
Name string
Critical bool
Description string
}
// ErrInvalidValue represents a GochanConfig field with a bad value
type ErrInvalidValue struct {
Field string
Value interface{}
Details string
}
func (iv *ErrInvalidValue) Error() string {
str := fmt.Sprintf("invalid %s value: %#v", iv.Field, iv.Value)
if iv.Details != "" {
str += " - " + iv.Details
}
return str
}
// copied from gcutil to avoid import loop
func findResource(paths ...string) string {
@ -12,3 +45,159 @@ func findResource(paths ...string) string {
}
return ""
}
// ParseJSON loads and parses JSON data, returning a GochanConfig pointer, any critical missing
// fields that don't have defaults, and any error from parsing the file. This doesn't mean that the
// values are valid, just that they exist
func ParseJSON(ba []byte) (*GochanConfig, []MissingField, error) {
var missing []MissingField
cfg := &GochanConfig{}
err := json.Unmarshal(ba, cfg)
if err != nil {
// checking for malformed JSON, invalid field types
return cfg, nil, err
}
var checker map[string]interface{} // using this for checking for missing fields
json.Unmarshal(ba, &checker)
cVal := reflect.ValueOf(cfg).Elem()
cType := reflect.TypeOf(*cfg)
numFields := cType.NumField()
for f := 0; f < numFields; f++ {
fType := cType.Field(f)
fVal := cVal.Field(f)
critical := fType.Tag.Get("critical") == "true"
if !fVal.CanSet() || fType.Tag.Get("json") == "-" {
// field is unexported and isn't read from the JSON file
continue
}
if checker[fType.Name] != nil {
// field is in the JSON file
continue
}
if cfgDefaults[fType.Name] != nil {
// the field isn't in the JSON file but has a default value that we can use
fVal.Set(reflect.ValueOf(cfgDefaults[fType.Name]))
continue
}
if critical {
// the field isn't in the JSON file and has no default value
missing = append(missing, MissingField{
Name: fType.Name,
Description: fType.Tag.Get("description"),
Critical: critical,
})
}
}
return cfg, missing, err
}
// InitConfig loads and parses gochan.json on startup and verifies its contents
func InitConfig(versionStr string) {
cfgPath = findResource("gochan.json", "/etc/gochan/gochan.json")
if cfgPath == "" {
fmt.Println("gochan.json not found")
os.Exit(1)
}
jfile, err := ioutil.ReadFile(cfgPath)
if err != nil {
fmt.Printf("Error reading %s: %s\n", cfgPath, err.Error())
os.Exit(1)
}
var fields []MissingField
Config, fields, err = ParseJSON(jfile)
if err != nil {
fmt.Printf("Error parsing %s: %s", cfgPath, err.Error())
}
Config.jsonLocation = cfgPath
numMissing := 0
for _, missing := range fields {
fmt.Println("Missing field:", missing.Name)
if missing.Description != "" {
fmt.Println("Description:", missing.Description)
}
numMissing++
}
if numMissing > 0 {
fmt.Println("gochan failed to load the configuration file because there are fields missing.\nSee gochan.example.json in sample-configs for an example configuration file")
os.Exit(1)
}
if err = Config.ValidateValues(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if _, err = os.Stat(Config.DocumentRoot); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if _, err = os.Stat(Config.TemplateDir); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if _, err = os.Stat(Config.LogDir); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
Config.LogDir = findResource(Config.LogDir, "log", "/var/log/gochan/")
if err = gclog.InitLogs(
path.Join(Config.LogDir, "access.log"),
path.Join(Config.LogDir, "error.log"),
path.Join(Config.LogDir, "staff.log"),
Config.DebugMode); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if Config.Port == 0 {
Config.Port = 80
}
if len(Config.FirstPage) == 0 {
Config.FirstPage = []string{"index.html", "1.html", "firstrun.html"}
}
if Config.SiteWebfolder == "" {
Config.SiteWebfolder = "/"
}
if Config.SiteWebfolder[0] != '/' {
Config.SiteWebfolder = "/" + Config.SiteWebfolder
}
if Config.SiteWebfolder[len(Config.SiteWebfolder)-1] != '/' {
Config.SiteWebfolder += "/"
}
if Config.EnableGeoIP {
if _, err = os.Stat(Config.GeoIPDBlocation); err != nil {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "Unable to find GeoIP file location set in gochan.json, disabling GeoIP")
}
Config.EnableGeoIP = false
}
_, zoneOffset := time.Now().Zone()
Config.TimeZone = zoneOffset / 60 / 60
Config.Version = ParseVersion(versionStr)
Config.Version.Normalize()
}
// reimplemented from gcutil.RandomString to avoid a dependency cycle
func randomString(length int) string {
var str string
for i := 0; i < length; i++ {
num := rand.Intn(127)
if num < 32 {
num += 32
}
str += fmt.Sprintf("%c", num)
}
return str
}

View file

@ -46,6 +46,6 @@ func TestGochanLog(t *testing.T) {
Println(LErrorLog, "Error log", "(Println)")
Println(LStaffLog, "Staff log", "(Println)")
Println(LAccessLog|LErrorLog, "Access and error log", "(Println)")
Println(LAccessLog|LStaffLog|LFatal, "Fatal access and staff log", "(Println)")
Println(LAccessLog, "This shouldn't be here", "(Println)")
// Println(LAccessLog|LStaffLog|LFatal, "Fatal access and staff log", "(Println)")
// Println(LAccessLog, "This shouldn't be here", "(Println)")
}

View file

@ -238,7 +238,7 @@ var funcMap = template.FuncMap{
return loopArr
},
"generateConfigTable": func() string {
configType := reflect.TypeOf(config.Config)
configType := reflect.TypeOf(*config.Config)
tableOut := `<table style="border-collapse: collapse;" id="config"><tr><th>Field name</th><th>Value</th><th>Type</th><th>Description</th></tr>`
numFields := configType.NumField()
for f := 17; f < numFields-2; f++ {

View file

@ -108,7 +108,6 @@ var actions = map[string]Action{
config.Config.Modboard = request.PostFormValue("Modboard")
config.Config.SiteName = request.PostFormValue("SiteName")
config.Config.SiteSlogan = request.PostFormValue("SiteSlogan")
config.Config.SiteHeaderURL = request.PostFormValue("SiteHeaderURL")
config.Config.SiteWebfolder = request.PostFormValue("SiteWebfolder")
// TODO: Change this to match the new Style type in gochan.json
/* Styles_arr := strings.Split(request.PostFormValue("Styles"), "\n")
@ -118,8 +117,7 @@ var actions = map[string]Action{
}
config.Styles = Styles */
config.Config.DefaultStyle = request.PostFormValue("DefaultStyle")
config.Config.AllowDuplicateImages = (request.PostFormValue("AllowDuplicateImages") == "on")
config.Config.AllowVideoUploads = (request.PostFormValue("AllowVideoUploads") == "on")
config.Config.RejectDuplicateImages = (request.PostFormValue("RejectDuplicateImages") == "on")
NewThreadDelay, err := strconv.Atoi(request.PostFormValue("NewThreadDelay"))
if err != nil {
status += err.Error() + "<br />"
@ -205,14 +203,6 @@ var actions = map[string]Action{
config.Config.StickyRepliesOnBoardPage = StickyRepliesOnBoardPage
}
BanColorsArr := strings.Split(request.PostFormValue("BanColors"), "\n")
var BanColors []string
for _, color := range BanColorsArr {
BanColors = append(BanColors, strings.Trim(color, " \n\r"))
}
config.Config.BanColors = BanColors
config.Config.BanMsg = request.PostFormValue("BanMsg")
EmbedWidth, err := strconv.Atoi(request.PostFormValue("EmbedWidth"))
if err != nil {
@ -230,7 +220,6 @@ var actions = map[string]Action{
config.Config.ExpandButton = (request.PostFormValue("ExpandButton") == "on")
config.Config.ImagesOpenNewTab = (request.PostFormValue("ImagesOpenNewTab") == "on")
config.Config.MakeURLsHyperlinked = (request.PostFormValue("MakeURLsHyperlinked") == "on")
config.Config.NewTabOnOutlinks = (request.PostFormValue("NewTabOnOutlinks") == "on")
config.Config.MinifyHTML = (request.PostFormValue("MinifyHTML") == "on")
config.Config.MinifyJS = (request.PostFormValue("MinifyJS") == "on")
@ -267,7 +256,6 @@ var actions = map[string]Action{
config.Config.MaxRecentPosts = MaxRecentPosts
}
config.Config.EnableAppeals = (request.PostFormValue("EnableAppeals") == "on")
MaxLogDays, err := strconv.Atoi(request.PostFormValue("MaxLogDays"))
if err != nil {
status += err.Error() + "<br />"
@ -288,7 +276,7 @@ var actions = map[string]Action{
}
manageConfigBuffer := bytes.NewBufferString("")
if err = gctemplates.ManageConfig.Execute(manageConfigBuffer,
map[string]interface{}{"config": config.Config, "status": status}); err != nil {
map[string]interface{}{"config": *config.Config, "status": status}); err != nil {
err = errors.New(gclog.Print(gclog.LErrorLog,
"Error executing config management page: ", err.Error()))
return htmlOut + err.Error(), err

View file

@ -243,7 +243,7 @@ func MakePost(writer http.ResponseWriter, request *http.Request) {
}
if filetype == "webm" {
if !allowsVids || !config.Config.AllowVideoUploads {
if !allowsVids {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LAccessLog,
"Video uploading is not currently enabled for this board."))
os.Remove(filePath)