The configuration-manager plug now creates and owns the managed CONFIG.md block on first save, so the server no longer needs to inject a template. Client also gracefully handles the absense of CONFIG.
339 lines
10 KiB
Go
339 lines
10 KiB
Go
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.
|
|
ensureIndex(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
|
|
|
|
func ensureIndex(sp server.SpacePrimitives, indexPage string) {
|
|
// Only create the index page 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 template
|
|
return
|
|
}
|
|
}
|
|
|
|
indexPagePath := fmt.Sprintf("%s.md", indexPage)
|
|
log.Printf("Empty space detected, creating %s", indexPagePath)
|
|
if _, err := sp.WriteFile(indexPagePath, indexPageContent, nil); err != nil {
|
|
log.Fatalf("Could not write index page %s: %v", indexPagePath, 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
|
|
}
|