package main import ( "crypto/rand" "encoding/hex" "flag" "log" "net/http" "os" "path/filepath" "strings" "time" "chfs-ng/internal/api" "chfs-ng/internal/auth" "chfs-ng/internal/config" "chfs-ng/internal/opslog" "chfs-ng/internal/storage" ) var version = "dev" func main() { showVersion := flag.Bool("version", false, "print version and exit") file := flag.String("file", "", "configuration file") pathFlag := flag.String("path", "/data", "shared path") port := flag.Int("port", 80, "listen port") allow := flag.String("allow", "", "allowed IPv4 addresses") var rules multiFlag flag.Var(&rules, "rule", "access rule (repeatable)") flag.Parse() if *showVersion { println(version) return } api.Version = version cfg := config.Default() if *file != "" { loaded, err := config.Load(*file) if err != nil && !os.IsNotExist(err) { log.Fatal(err) } if err == nil { cfg = loaded } } flag.CommandLine.Visit(func(f *flag.Flag) { switch f.Name { case "path": cfg.Path = splitPath(*pathFlag) case "port": cfg.Port = *port case "allow": cfg.Allow = *allow case "rule": cfg.Rules = append([]string(nil), rules...) } }) if len(cfg.Path) == 0 { cfg.Path = []string{"/data"} } if err := cfg.Validate(); err != nil { log.Fatal(err) } root := cfg.Path[0] for _, p := range cfg.Path { if err := os.MkdirAll(p, 0755); err != nil { log.Fatal(err) } } mgr, err := auth.ParseRules(cfg.Rules) if err != nil { log.Fatal(err) } for _, ac := range cfg.Accounts { admin := ac.Role == "admin" || strings.EqualFold(ac.Username, "admin") mask := auth.ParseMask(ac.Default) if admin && ac.Default == "" { mask = auth.Read | auth.Write | auth.Delete } a := &auth.Account{Username: ac.Username, Password: ac.Password, Mask: mask, Admin: admin} for _, d := range ac.None { a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Deny: true}) } for _, d := range ac.Read { a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Mask: auth.Read, ChildrenOnly: true}) } for _, d := range ac.Write { a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Mask: auth.ParseMask("w"), ChildrenOnly: true}) } for _, d := range ac.Delete { a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Mask: auth.ParseMask("d"), ChildrenOnly: true}) } if ac.Username == "guest" { mgr.Guest = a } else { mgr.Accounts[ac.Username] = a } } if cfg.UserHome == "" { cfg.UserHome = "users" } multi := storage.NewMulti(cfg.Path) if len(cfg.Path) > 1 { if _, _, ok := multi.FindRoot(cfg.UserHome); !ok { cfg.UserHome = multi.Names[0] + "/" + strings.Trim(cfg.UserHome, "/") } } store := storage.Storage(multi) if len(cfg.Path) == 1 { store = storage.Local{Root: root} } userFile := cfg.UserFile if userFile == "" { if dir, e := os.UserConfigDir(); e == nil { userFile = filepath.Join(dir, "chfs-ng", "users.json") } else { userFile = filepath.Join(root, ".chfs-users.json") } } else if !filepath.IsAbs(userFile) { userFile = filepath.Join(root, userFile) } if err := os.MkdirAll(filepath.Dir(userFile), 0700); err != nil { log.Fatal(err) } users := &auth.UserStore{Path: userFile} if err := users.Load(mgr, cfg.UserHome); err != nil { log.Fatal(err) } jwtSecret := cfg.JWTSecret if jwtSecret == "" { // Keep sessions valid across process restarts. A random in-memory secret // would invalidate every login whenever the service is restarted. secretPath := userFile + ".jwt-secret" if saved, err := os.ReadFile(secretPath); err == nil && len(strings.TrimSpace(string(saved))) >= 32 { jwtSecret = strings.TrimSpace(string(saved)) } else { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { log.Fatal(err) } jwtSecret = hex.EncodeToString(buf) if err := os.WriteFile(secretPath, []byte(jwtSecret+"\n"), 0600); err != nil { log.Printf("warning: could not persist JWT secret at %s: %v", secretPath, err) } } } for _, a := range mgr.Accounts { if a.Home != "" { if err := store.MkdirAll(a.Home); err != nil { log.Fatal(err) } } } srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, TrustedProxies: cfg.TrustedProxies, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome, ThumbnailMaxSize: cfg.ThumbnailMaxSize, ThumbnailMaxPixels: cfg.ThumbnailMaxPixels, ZipMaxSize: cfg.ZipMaxSize, ZipMaxFiles: cfg.ZipMaxFiles, UploadMaxSize: cfg.UploadMaxSize, UploadMaxTotalSize: cfg.UploadMaxTotalSize, UploadMaxFiles: cfg.UploadMaxFiles, SearchMaxResults: cfg.SearchMaxResults} addr := ":" + itoa(cfg.Port) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) if cfg.SSLCert != "" && cfg.SSLKey != "" { log.Fatal(http.ListenAndServeTLS(addr, cfg.SSLCert, cfg.SSLKey, srv.Handler())) } log.Fatal(http.ListenAndServe(addr, srv.Handler())) } type multiFlag []string func (m *multiFlag) String() string { return strings.Join(*m, "|") } func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } func accountRulePath(rule string, roots []string) string { rule = strings.TrimSpace(strings.Trim(rule, "\\\"")) if rule == "" { return rule } rule = filepath.ToSlash(rule) if !filepath.IsAbs(rule) { // CHFS section rules are filesystem paths. Do not silently rebase a // relative rule onto --path; the original parser treats such a value // as a literal path and it therefore does not match a virtual path. return rule } for _, root := range roots { r := filepath.Clean(root) rel, err := filepath.Rel(r, rule) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { continue } if rel == "." { return "" } return filepath.ToSlash(rel) } return rule } func itoa(n int) string { const d = "0123456789" if n == 0 { return "0" } b := make([]byte, 0, 6) for n > 0 { b = append([]byte{d[n%10]}, b...) n /= 10 } return string(b) } func splitPath(v string) []string { var out []string for _, p := range strings.Split(v, "|") { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }