Files

220 lines
6.5 KiB
Go

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
}