// Niltalk, April 2015 // License AGPL3 package main import ( "errors" "fmt" "html/template" "io/ioutil" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/go-chi/chi" "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/niltalk/internal/hub" "github.com/knadh/niltalk/store" "github.com/knadh/niltalk/store/fs" "github.com/knadh/niltalk/store/mem" "github.com/knadh/niltalk/store/redis" "github.com/knadh/stuffbin" flag "github.com/spf13/pflag" ) var ( logger = log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile) ko = koanf.New(".") // Version of the build injected at build time. buildString = "unknown" ) // App is the global app context that's passed around. type App struct { hub *hub.Hub cfg *hub.Config tpl *template.Template fs stuffbin.FileSystem logger *log.Logger } func loadConfig() { // Register --help handler. f := flag.NewFlagSet("config", flag.ContinueOnError) f.Usage = func() { fmt.Println(f.FlagUsages()) os.Exit(0) } f.StringSlice("config", []string{"config.toml"}, "Path to one or more TOML config files to load in order") f.Bool("new-config", false, "generate sample config file") f.Bool("onion", false, "Show the onion URL") f.String("static-dir", "", "(optional) path to directory with static files") f.Bool("version", false, "Show build version") f.Parse(os.Args[1:]) // Display version. if ok, _ := f.GetBool("version"); ok { fmt.Println(buildString) os.Exit(0) } // Generate new config. if ok, _ := f.GetBool("new-config"); ok { if err := newConfigFile(); err != nil { logger.Println(err) os.Exit(1) } logger.Println("generated config.toml. Edit and run the app.") os.Exit(0) } // Read the config files. cFiles, _ := f.GetStringSlice("config") for _, f := range cFiles { logger.Printf("reading config: %s", f) if err := ko.Load(file.Provider(f), toml.Parser()); err != nil { if os.IsNotExist(err) { logger.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.") } logger.Fatalf("error loadng config from file: %v.", err) } } // Merge env flags into config. if err := ko.Load(env.Provider("NILTALK_", ".", func(s string) string { return strings.Replace(strings.ToLower( strings.TrimPrefix(s, "NILTALK_")), "__", ".", -1) }), nil); err != nil { logger.Printf("error loading env config: %v", err) } // Merge command line flags into config. ko.Load(posflag.Provider(f, ".", ko), nil) } // initFS initializes the stuffbin embedded static filesystem. func initFS(staticDir string) stuffbin.FileSystem { // Get self executable path to initialise stuffed FS. exe, err := os.Executable() if err != nil { log.Fatalf("error getting executable path: %v", err) } // Read stuffed data from self. fs, err := stuffbin.UnStuff(exe) if err != nil { // Binary is unstuffed or is running in dev mode. // Can halt here or fall back to the local filesystem. if err == stuffbin.ErrNoID { // First argument is to the root to mount the files in the FileSystem // and the rest of the arguments are paths to embed. fs, err = stuffbin.NewLocalFS("./", "./static/templates", "./static/static:/static", "config.toml.sample") if err != nil { log.Fatalf("error falling back to local filesystem: %v", err) } } else { log.Fatalf("error reading stuffed binary: %v", err) } } // Optional static directory to override files. if staticDir != "" { logger.Printf("loading static files from: %v", staticDir) fStatic, err := stuffbin.NewLocalFS("/", filepath.Join(staticDir, "/templates")+":/static/templates", filepath.Join(staticDir, "/static")+":/static", ) if err != nil { logger.Fatalf("failed reading static directory: %s: %v", staticDir, err) } if err := fs.Merge(fStatic); err != nil { logger.Fatalf("error merging static directory: %s: %v", staticDir, err) } } return fs } // Catch OS interrupts and respond accordingly. // This is not fool proof as http keeps listening while // existing rooms are shut down. func catchInterrupts() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) go func() { for sig := range c { // Shutdown. logger.Printf("shutting down: %v", sig) os.Exit(0) } }() } func newConfigFile() error { if _, err := os.Stat("config.toml"); !os.IsNotExist(err) { return errors.New("config.toml exists. Remove it to generate a new one") } // Initialize the static file system into which all // required static assets (.sql, .js files etc.) are loaded. fs := initFS("") b, err := fs.Read("config.toml.sample") if err != nil { return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err) } return ioutil.WriteFile("config.toml", b, 0644) } func main() { // Load configuration from files. loadConfig() // Initialize global app context. app := &App{ logger: logger, fs: initFS(ko.String("static-dir")), } if err := ko.Unmarshal("app", &app.cfg); err != nil { logger.Fatalf("error unmarshalling 'app' config: %v", err) } minTime := time.Duration(3) * time.Second if app.cfg.RoomAge < minTime || app.cfg.WSTimeout < minTime { logger.Fatal("app.websocket_timeout and app.roomage should be > 3s") } // Initialize store. var store store.Store if app.cfg.Storage == "redis" { var storeCfg redis.Config if err := ko.Unmarshal("store", &storeCfg); err != nil { logger.Fatalf("error unmarshalling 'store' config: %v", err) } s, err := redis.New(storeCfg) if err != nil { log.Fatalf("error initializing store: %v", err) } store = s } else if app.cfg.Storage == "memory" { var storeCfg mem.Config if err := ko.Unmarshal("store", &storeCfg); err != nil { logger.Fatalf("error unmarshalling 'store' config: %v", err) } s, err := mem.New(storeCfg) if err != nil { log.Fatalf("error initializing store: %v", err) } store = s } else if app.cfg.Storage == "fs" { var storeCfg fs.Config if err := ko.Unmarshal("store", &storeCfg); err != nil { logger.Fatalf("error unmarshalling 'store' config: %v", err) } s, err := fs.New(storeCfg, logger) if err != nil { log.Fatalf("error initializing store: %v", err) } store = s } else { logger.Fatal("app.storage must be one of redis|memory|fs") } if ko.Bool("onion") { pk, err := getOrCreatePK(store) if err != nil { logger.Fatal(err) } fmt.Printf("http://%v.onion\n", onionAddr(pk)) os.Exit(0) } app.hub = hub.NewHub(app.cfg, store, logger) // Compile static templates. tpl, err := stuffbin.ParseTemplatesGlob(nil, app.fs, "/static/templates/*.html") if err != nil { logger.Fatalf("error compiling templates: %v", err) } app.tpl = tpl // Register HTTP routes. r := chi.NewRouter() r.Get("/", wrap(handleIndex, app, 0)) r.Get("/ws/{roomID}", wrap(handleWS, app, hasAuth|hasRoom)) // API. r.Post("/api/rooms/{roomID}/login", wrap(handleLogin, app, hasRoom)) r.Delete("/api/rooms/{roomID}/login", wrap(handleLogout, app, hasAuth|hasRoom)) r.Post("/api/rooms", wrap(handleCreateRoom, app, 0)) // Views. r.Get("/r/{roomID}", wrap(handleRoomPage, app, hasAuth|hasRoom)) r.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { app.fs.FileServer().ServeHTTP(w, r) }) // Start the app. var srv interface { ListenAndServe() error } if appAddress := ko.String("app.address"); appAddress == "tor" { pk, err := getOrCreatePK(store) if err != nil { logger.Fatalf("could not create the private key file: %v", err) } srv = &torServer{ PrivateKey: pk, Handler: r, } logger.Printf("starting server on http://%v.onion", onionAddr(pk)) } else { srv = &http.Server{ Addr: appAddress, Handler: r, } logger.Printf("starting server on http://%v", appAddress) } if err := srv.ListenAndServe(); err != nil { logger.Fatalf("couldn't start server: %v", err) } }