package cmd import ( _ "embed" "fmt" "html/template" "io/fs" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/silverbulletmd/silverbullet/server" "github.com/spf13/cobra" ) func buildConfig(bundledFiles fs.FS, args []string, buildTime string) *server.ServerConfig { serverConfig := &server.ServerConfig{ BindHost: "127.0.0.1", Port: 3000, } rootSpaceConfig := &server.SpaceConfig{ IndexPage: "index", SpaceName: "SilverBullet", SpaceDescription: "Powerful and programmable note taking app", } // For now just point every request to the rootSpaceConfig serverConfig.SpaceConfigResolver = func(r *http.Request) (*server.SpaceConfig, error) { return rootSpaceConfig, nil } if os.Getenv("SB_HOSTNAME") != "" { serverConfig.BindHost = os.Getenv("SB_HOSTNAME") } if os.Getenv("SB_PORT") != "" { // Parse integer from string port, err := strconv.ParseInt(os.Getenv("SB_PORT"), 10, 32) if err != nil { log.Fatalf("Could not parse numeric port from SB_PORT: %v", err) } serverConfig.Port = int(port) } if os.Getenv("SB_INDEX_PAGE") != "" { rootSpaceConfig.IndexPage = os.Getenv("SB_INDEX_PAGE") } if len(args) > 0 { rootSpaceConfig.SpaceFolderPath = args[0] } if os.Getenv("SB_FOLDER") != "" { rootSpaceConfig.SpaceFolderPath = os.Getenv("SB_FOLDER") } if os.Getenv("SB_LOG_PUSH") != "" { rootSpaceConfig.LogPush = true log.Println("Client log push enabled") } rootSpaceConfig.GitIgnore = os.Getenv("SB_SPACE_IGNORE") if os.Getenv("SB_SPACE_IGNORE") != "" { log.Printf("Ignoring files matching: %s", os.Getenv("SB_SPACE_IGNORE")) } if rootSpaceConfig.SpaceFolderPath == "" { log.Fatal("No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.") } var spacePrimitives server.SpacePrimitives spacePrimitives, err := server.NewDiskSpacePrimitives(rootSpaceConfig.SpaceFolderPath, rootSpaceConfig.GitIgnore) rootSpaceConfig.ReadOnlyMode = os.Getenv("SB_READ_ONLY") != "" if rootSpaceConfig.ReadOnlyMode { log.Println("Starting in read-only mode.") } if rootSpaceConfig.ReadOnlyMode { // Wrap the space primitives in read only mode spacePrimitives = server.NewReadOnlySpacePrimitives(spacePrimitives) } if err != nil { log.Fatal(err) } serverConfig.EnableHTTPLogging = os.Getenv("SB_HTTP_LOGGING") != "" if os.Getenv("SB_METRICS_PORT") != "" { serverConfig.MetricsPort, err = strconv.Atoi(os.Getenv("SB_METRICS_PORT")) if err != nil { log.Fatalf("Could not parse SB_METRICS_PORT as number: %v", err) } } if os.Getenv("SB_USER") != "" { pieces := strings.SplitN(os.Getenv("SB_USER"), ":", 2) if len(pieces) != 2 { log.Fatal("SB_USER must be in the format user:pass") } rootSpaceConfig.Auth = &server.AuthOptions{ User: pieces[0], Pass: pieces[1], AuthToken: os.Getenv("SB_AUTH_TOKEN"), LockoutLimit: 10, LockoutTime: 60, } rootSpaceConfig.Authorize = func(username, password string) bool { return username == rootSpaceConfig.Auth.User && password == rootSpaceConfig.Auth.Pass } if os.Getenv("SB_LOCKOUT_LIMIT") != "" { rootSpaceConfig.Auth.LockoutLimit, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_LIMIT")) if err != nil { log.Fatalf("Could not parse SB_LOCKOUT_LIMIT as number: %v", err) } } if os.Getenv("SB_LOCKOUT_TIME") != "" { rootSpaceConfig.Auth.LockoutTime, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_TIME")) if err != nil { log.Fatalf("Could not parse SB_LOCKOUT_TIME as number: %v", err) } } rootSpaceConfig.Auth.RememberMeHours = 168 // default 7 days if os.Getenv("SB_REMEMBER_ME_HOURS") != "" { rootSpaceConfig.Auth.RememberMeHours, err = strconv.Atoi(os.Getenv("SB_REMEMBER_ME_HOURS")) if err != nil { log.Fatalf("Could not parse SB_REMEMBER_ME_HOURS as number: %v", err) } } log.Printf("User authentication enabled for user \"%s\" with lockout limit %d and lockout time %ds", pieces[0], rootSpaceConfig.Auth.LockoutLimit, rootSpaceConfig.Auth.LockoutTime) } if os.Getenv("SB_NAME") != "" { rootSpaceConfig.SpaceName = os.Getenv("SB_NAME") } if os.Getenv("SB_DESCRIPTION") != "" { rootSpaceConfig.SpaceDescription = os.Getenv("SB_DESCRIPTION") } rootSpaceConfig.AdditionalHeadHTML = template.HTML(os.Getenv("SB_HEAD_HTML")) if os.Getenv("SB_URL_PREFIX") != "" { hostUrlPrefix := os.Getenv("SB_URL_PREFIX") // Normalize the host URL prefix (if set) if !strings.HasPrefix(hostUrlPrefix, "/") { hostUrlPrefix = "/" + hostUrlPrefix } hostUrlPrefix = strings.TrimRight(hostUrlPrefix, "/") if hostUrlPrefix != "" { log.Printf("Host URL Prefix: %s", hostUrlPrefix) serverConfig.HostURLPrefix = hostUrlPrefix } } // Determine bundle timestamp from build-time injected value, fall back to current time var bundlePathDate time.Time if buildTime != "" { if t, err := time.Parse(time.RFC3339, buildTime); err == nil { bundlePathDate = t } } if bundlePathDate.IsZero() { log.Println("Couldn't determine build time of binary, falling back to current time stamp") bundlePathDate = time.Now() } // Ensure template files exist before wrapping with base_fs fallthrough, // because the fallthrough layer includes .md files that would make the space appear non-empty. ensureIndexAndConfig(spacePrimitives, rootSpaceConfig.IndexPage) serverConfig.ClientBundle = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "client", bundlePathDate, nil) rootSpaceConfig.SpacePrimitives = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "base_fs", bundlePathDate, spacePrimitives) if serverConfig.BindHost == "127.0.0.1" { log.Println("SilverBullet will only be available locally, to allow outside connections, pass -L0.0.0.0 as a flag, and put a TLS terminator on top.") } // Runtime API: auto-enable if Chrome is available, unless explicitly disabled or read-only runtimeAPIEnv := os.Getenv("SB_RUNTIME_API") if runtimeAPIEnv == "0" || runtimeAPIEnv == "false" { log.Println("Runtime API explicitly disabled via SB_RUNTIME_API=0") } else if rootSpaceConfig.ReadOnlyMode { log.Println("Runtime API is not available in read-only mode") } else { chromePath := os.Getenv("SB_CHROME_PATH") if chromePath == "" { chromePath = os.Getenv("CHROMIUM_PATH") } if chromePath == "" { chromePath = findChrome() } if chromePath != "" { rootSpaceConfig.EnableRuntimeAPI = true chromeDataDir := os.Getenv("SB_CHROME_DATA_DIR") if chromeDataDir == "" { chromeDataDir = filepath.Join(rootSpaceConfig.SpaceFolderPath, ".chrome-data") } serverConfig.HeadlessConfig = &server.HeadlessConfig{ ChromePath: chromePath, ShowBrowser: os.Getenv("SB_CHROME_SHOW") != "", UserDataDir: chromeDataDir, } log.Printf("Runtime API enabled (Chrome: %s, data: %s)", chromePath, chromeDataDir) } else { log.Println("Runtime API disabled: Chrome/Chromium not found. Install Chrome or set SB_CHROME_PATH to enable.") } } // Initialize shell backend backendConfig := os.Getenv("SB_SHELL_BACKEND") if backendConfig == "" && !rootSpaceConfig.ReadOnlyMode { localShell := server.NewLocalShell(rootSpaceConfig.SpaceFolderPath, os.Getenv("SB_SHELL_WHITELIST")) rootSpaceConfig.ShellBackend = localShell if localShell.AllowAllCmds { log.Println("Local shell command execution enabled for ALL commands.") } else { log.Printf("Local shell command execution enabled for: %v", localShell.CmdWhiteList) } } else { log.Println("Shell running disabled.") rootSpaceConfig.ShellBackend = server.NewNotSupportedShell() } return serverConfig } //go:embed space_template/index.md var indexPageContent []byte //go:embed space_template/CONFIG.md var configPageContent []byte func ensureIndexAndConfig(sp server.SpacePrimitives, indexPage string) { // Only create template files in truly empty spaces (no .md files yet) files, err := sp.FetchFileList() if err != nil { log.Printf("Warning: could not check space state: %v", err) return } for _, f := range files { if strings.HasSuffix(f.Name, ".md") { // Space has existing content, don't inject templates return } } indexPagePath := fmt.Sprintf("%s.md", indexPage) log.Printf("Empty space detected, creating %s and CONFIG.md", indexPagePath) if _, err := sp.WriteFile(indexPagePath, indexPageContent, nil); err != nil { log.Fatalf("Could not write index page %s: %v", indexPagePath, err) } if _, err := sp.WriteFile("CONFIG.md", configPageContent, nil); err != nil { log.Fatalf("Could not write config page: %v", err) } } func findChrome() string { var candidates []string switch runtime.GOOS { case "darwin": candidates = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", } case "windows": candidates = []string{ "chrome", "chrome.exe", `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, `C:\Program Files\Google\Chrome\Application\chrome.exe`, filepath.Join(os.Getenv("USERPROFILE"), `AppData\Local\Google\Chrome\Application\chrome.exe`), filepath.Join(os.Getenv("USERPROFILE"), `AppData\Local\Chromium\Application\chrome.exe`), } default: candidates = []string{ "headless_shell", "headless-shell", "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", } } for _, c := range candidates { if found, err := exec.LookPath(c); err == nil { return found } } return "" } func ServerCommand(bundledFiles fs.FS, versionFileText string, buildTime string) *cobra.Command { var hostname string var port int var c = &cobra.Command{ Use: "silverbullet", Short: "Run the SilverBullet server", Args: cobra.MinimumNArgs(0), Run: func(cmd *cobra.Command, args []string) { if version, err := server.ParseVersionFromTypeScript(versionFileText); err == nil { log.Printf("Starting SilverBullet version %s", version) } serverConfig := buildConfig(bundledFiles, args, buildTime) version, err := server.ParseVersionFromTypeScript(versionFileText) if err != nil { log.Fatal("Could not parse version from version file, this should never happen.") } serverConfig.Version = version if port != 3000 { serverConfig.Port = port } if hostname != "" { serverConfig.BindHost = hostname } if err := server.RunServer(serverConfig); err != nil { log.Fatal(err) } }, } c.Flags().StringVarP(&hostname, "hostname", "L", "", "Host or address to listen to") c.Flags().IntVarP(&port, "port", "p", 3000, "Port to listen to") return c }