feat: implement legacy config auth storage and core api

This commit is contained in:
OpenClaw Agent
2026-09-21 13:02:34 +08:00
parent 83695cea81
commit 79070af15d
6 changed files with 600 additions and 26 deletions
+53 -5
View File
@@ -1,14 +1,62 @@
package main package main
import ( import (
"chfs-ng/internal/api" "flag"
"chfs-ng/internal/storage"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"chfs-ng/internal/api"
"chfs-ng/internal/auth"
"chfs-ng/internal/config"
"chfs-ng/internal/storage"
) )
func main() { func main() {
s := &api.Server{Store: storage.Local{Root: "/data"}} file := flag.String("file", "", "configuration file")
log.Println("chfs-ng development server listening on :80") pathFlag := flag.String("path", "/data", "shared path")
log.Fatal(http.ListenAndServe(":80", s.Handler())) port := flag.Int("port", 80, "listen port")
flag.Parse()
cfg := config.Default()
if *file != "" {
var err error
cfg, err = config.Load(*file)
if err != nil && !os.IsNotExist(err) {
log.Fatal(err)
}
}
if *pathFlag != "/data" || *file == "" {
if *file == "" {
cfg.Path = []string{*pathFlag}
cfg.Port = *port
}
}
root := cfg.Path[0]
if e := os.MkdirAll(root, 0755); e != nil {
log.Fatal(e)
}
mgr, _ := auth.ParseRules(cfg.Rules)
srv := &api.Server{Store: storage.Local{Root: root}, Auth: mgr, Sessions: map[string]*auth.Account{}}
addr := ":" + itoa(cfg.Port)
if cfg.Port == 0 {
addr = ":" + itoa(*port)
}
log.Printf("chfs-ng listening on %s root=%s", addr, filepath.Clean(root))
if cfg.SSLCert != "" && cfg.SSLKey != "" {
log.Fatal(http.ListenAndServeTLS(addr, cfg.SSLCert, cfg.SSLKey, srv.Handler()))
}
log.Fatal(http.ListenAndServe(addr, srv.Handler()))
}
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)
} }
+211 -9
View File
@@ -1,11 +1,33 @@
package api package api
import ( import (
"chfs-ng/internal/storage" "encoding/json"
"io"
"mime/multipart"
"net/http" "net/http"
"path"
"strings"
"time"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
) )
type Server struct{ Store storage.Storage } type Server struct {
Store storage.Storage
Auth *auth.Manager
Sessions map[string]*auth.Account
}
type fileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Modified string `json:"modified"`
Dir bool `json:"dir"`
Mask string `json:"mask"`
GuestMask string `json:"guestMask"`
HasSubDir bool `json:"hasSubDir"`
}
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -15,12 +37,192 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/chfs/rmfiles", s.remove) mux.HandleFunc("/chfs/rmfiles", s.remove)
mux.HandleFunc("/chfs/exist", s.exist) mux.HandleFunc("/chfs/exist", s.exist)
mux.HandleFunc("/chfs/session", s.session) mux.HandleFunc("/chfs/session", s.session)
mux.HandleFunc("/chfs/newtext", s.newtext)
mux.HandleFunc("/chfs/updatetext", s.updatetext)
mux.HandleFunc("/chfs/upload", s.upload)
mux.HandleFunc("/chfs/upload2", s.upload)
return mux return mux
} }
func notImpl(w http.ResponseWriter) { http.Error(w, "not implemented", http.StatusNotImplemented) } func account(r *http.Request, s *Server) *auth.Account {
func (s *Server) files(w http.ResponseWriter, r *http.Request) { notImpl(w) } if c, e := r.Cookie("chfs_token"); e == nil {
func (s *Server) newdir(w http.ResponseWriter, r *http.Request) { notImpl(w) } if a := s.Sessions[c.Value]; a != nil {
func (s *Server) rename(w http.ResponseWriter, r *http.Request) { notImpl(w) } return a
func (s *Server) remove(w http.ResponseWriter, r *http.Request) { notImpl(w) } }
func (s *Server) exist(w http.ResponseWriter, r *http.Request) { notImpl(w) } }
func (s *Server) session(w http.ResponseWriter, r *http.Request) { notImpl(w) } return s.Auth.Guest
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(v)
}
func qpath(r *http.Request) string {
return strings.Trim(strings.ReplaceAll(r.URL.Query().Get("filepath"), "\\", "/"), "/")
}
func permString(p auth.PermMask) string {
if p.HasDelete() {
return "d"
}
if p.HasWrite() {
return "w"
}
if p.HasRead() {
return "r"
}
return ""
}
func (s *Server) files(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
p := qpath(r)
m := a.MaskFor(p)
if !m.HasRead() {
http.Error(w, "forbidden", 403)
return
}
list, e := s.Store.List(p)
if e != nil {
http.Error(w, e.Error(), 404)
return
}
out := make([]fileInfo, 0, len(list))
for _, fi := range list {
fp := path.Join(p, fi.Name())
sub := false
if fi.IsDir() {
if xs, e := s.Store.List(fp); e == nil {
sub = len(xs) > 0
}
}
out = append(out, fileInfo{Name: fi.Name(), Path: fp, Size: fi.Size(), Modified: fi.ModTime().Format(time.RFC3339), Dir: fi.IsDir(), Mask: permString(a.MaskFor(fp)), HasSubDir: sub})
}
writeJSON(w, map[string]any{"files": out, "currentMask": permString(m), "virtualRoot": false})
}
func (s *Server) newdir(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
p := qpath(r)
if !a.MaskFor(p).HasWrite() {
http.Error(w, "forbidden", 403)
return
}
name := r.FormValue("name")
if name == "" {
name = r.FormValue("new")
}
if name == "" {
http.Error(w, "name required", 400)
return
}
if e := s.Store.MkdirAll(path.Join(p, name)); e != nil {
http.Error(w, e.Error(), 500)
return
}
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) rename(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
old := strings.Trim(r.FormValue("old"), "/")
neu := strings.Trim(r.FormValue("new"), "/")
if !a.MaskFor(old).HasWrite() {
http.Error(w, "forbidden", 403)
return
}
if e := s.Store.Rename(old, neu); e != nil {
http.Error(w, e.Error(), 500)
return
}
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) remove(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
_ = r.ParseForm()
items := r.Form["path"]
if len(items) == 0 {
items = r.Form["paths"]
}
for _, p := range items {
if !a.MaskFor(p).HasDelete() {
http.Error(w, "forbidden", 403)
return
}
if e := s.Store.Remove(p); e != nil {
http.Error(w, e.Error(), 500)
return
}
}
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
p := qpath(r)
_, e := s.Store.Stat(p)
writeJSON(w, map[string]any{"exist": e == nil})
}
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
a := account(r, s)
writeJSON(w, map[string]any{"login": a != nil, "user": a.Username})
return
}
user, pwd := r.FormValue("user"), r.FormValue("pwd")
a := s.Auth.Authenticate(user, pwd)
if a == nil {
http.Error(w, "invalid account", 401)
return
}
token := auth.NewToken()
s.Sessions[token] = a
http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode})
writeJSON(w, map[string]any{"ok": true, "user": a.Username})
}
func (s *Server) newtext(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
p := strings.Trim(r.FormValue("path"), "/")
if !a.MaskFor(p).HasWrite() {
http.Error(w, "forbidden", 403)
return
}
f, e := s.Store.Create(p)
if e != nil {
http.Error(w, e.Error(), 500)
return
}
defer f.Close()
io.WriteString(f, r.FormValue("text"))
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) { s.newtext(w, r) }
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
dir := strings.Trim(r.FormValue("path"), "/")
if !a.MaskFor(dir).HasWrite() {
http.Error(w, "forbidden", 403)
return
}
if e := r.ParseMultipartForm(64 << 20); e != nil {
http.Error(w, e.Error(), 400)
return
}
var saved []string
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
if e := savePart(s.Store, dir, h); e != nil {
http.Error(w, e.Error(), 500)
return
}
saved = append(saved, h.Filename)
}
}
writeJSON(w, map[string]any{"ok": true, "files": saved})
}
func savePart(st storage.Storage, dir string, h *multipart.FileHeader) error {
in, e := h.Open()
if e != nil {
return e
}
defer in.Close()
f, e := st.Create(path.Join(dir, h.Filename))
if e != nil {
return e
}
defer f.Close()
_, e = io.Copy(f, in)
return e
}
+115
View File
@@ -1,5 +1,12 @@
package auth package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
)
type PermMask uint8 type PermMask uint8
const ( const (
@@ -12,8 +19,116 @@ const (
func (p PermMask) HasRead() bool { return p&Read != 0 } func (p PermMask) HasRead() bool { return p&Read != 0 }
func (p PermMask) HasWrite() bool { return p&Write != 0 } func (p PermMask) HasWrite() bool { return p&Write != 0 }
func (p PermMask) HasDelete() bool { return p&Delete != 0 } func (p PermMask) HasDelete() bool { return p&Delete != 0 }
func ParseMask(s string) PermMask {
var p PermMask
for _, r := range strings.ToLower(s) {
switch r {
case 'r':
p |= Read
case 'w':
p |= Read | Write
case 'd':
p |= Read | Write | Delete
}
}
return p
}
type PathRule struct {
Dir string
Mask PermMask
}
type Account struct { type Account struct {
Username, Password string Username, Password string
Mask PermMask Mask PermMask
Rules []PathRule
}
type Manager struct {
Accounts map[string]*Account
Guest *Account
}
func ParseRules(values []string) (*Manager, error) {
m := &Manager{Accounts: map[string]*Account{}}
for _, value := range values {
for _, item := range strings.Split(value, "|") {
if item == "" {
continue
}
fields := strings.Split(item, ":")
if len(fields) < 3 || len(fields)%2 == 0 {
continue
}
a := &Account{Username: fields[0], Password: fields[1], Mask: ParseMask(fields[2])}
for i := 3; i+1 < len(fields); i += 2 {
a.Rules = append(a.Rules, PathRule{Dir: strings.Trim(fields[i], "/"), Mask: ParseMask(fields[i+1])})
}
if a.Username == "" {
m.Guest = a
} else {
m.Accounts[a.Username] = a
}
}
}
if m.Guest == nil {
m.Guest = &Account{Mask: Read | Write}
}
return m, nil
}
func (m *Manager) Authenticate(user, pwd string) *Account {
if a, ok := m.Accounts[user]; ok && a.Password == pwd {
return a
}
if user == "" && pwd == "" {
return m.Guest
}
return nil
}
func (a *Account) MaskFor(path string) PermMask {
path = strings.Trim(path, "/")
best := -1
mask := a.Mask
for _, r := range a.Rules {
d := strings.Trim(r.Dir, "/")
if matchPath(d, path) && len(d) > best {
best = len(d)
mask = r.Mask
}
}
return mask
}
func matchPath(pattern, path string) bool {
if pattern == "" {
return path == ""
}
pp := strings.Split(pattern, "/")
ps := strings.Split(path, "/")
if len(ps) < len(pp) {
return false
}
for i, p := range pp {
if p == "*" {
return true
}
if p == "?" {
if ps[i] == "" {
return false
}
continue
}
if p != ps[i] {
return false
}
}
return true
}
func NewToken() string {
b := make([]byte, 32)
if _, e := rand.Read(b); e != nil {
h := sha256.Sum256([]byte("fallback"))
return hex.EncodeToString(h[:])
}
return hex.EncodeToString(b)
} }
+20
View File
@@ -0,0 +1,20 @@
package auth
import "testing"
func TestParseRules(t *testing.T) {
m, err := ParseRules([]string{"::r|admin:secret:rw:private:d"})
if err != nil {
t.Fatal(err)
}
if !m.Guest.Mask.HasRead() || m.Guest.Mask.HasWrite() {
t.Fatal("guest mask")
}
a := m.Accounts["admin"]
if a == nil || !a.Mask.HasWrite() || a.Mask.HasDelete() {
t.Fatal("admin mask")
}
if got := a.MaskFor("private/x"); !got.HasDelete() {
t.Fatalf("subrule=%v", got)
}
}
+128
View File
@@ -0,0 +1,128 @@
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Path []string
Port int
Allow string
Rules []string
Log string
FileRemove int
ImagePreview bool
FolderDownload string
SessionTimeout int
HTMLTitle string
HTMLNotice string
SSLCert string
SSLKey string
}
func Default() Config {
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30}
}
func Load(path string) (Config, error) {
c := Default()
f, err := os.Open(path)
if err != nil {
return c, err
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
i := strings.IndexByte(line, '=')
if i < 0 {
continue
}
key, val := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:])
val = strings.Trim(val, "\\\"")
switch strings.ToLower(key) {
case "path":
c.Path = splitPath(val)
case "port":
if n, e := strconv.Atoi(val); e == nil && n > 0 && n < 65536 {
c.Port = n
}
case "allow":
c.Allow = val
case "rule":
if val != "" {
c.Rules = append(c.Rules, val)
}
case "log":
c.Log = val
case "file.remove":
if n, e := strconv.Atoi(val); e == nil {
c.FileRemove = n
}
case "image.preview":
c.ImagePreview = parseBool(val, c.ImagePreview)
case "folder.download":
c.FolderDownload = strings.ToLower(val)
case "folder.leaf.download":
if parseBool(val, false) {
c.FolderDownload = "leaf"
}
case "session.timeout":
if n, e := strconv.Atoi(val); e == nil && n > 0 {
c.SessionTimeout = n
}
case "html.title":
c.HTMLTitle = val
case "html.notice":
c.HTMLNotice = val
case "ssl.cert":
c.SSLCert = val
case "ssl.key":
c.SSLKey = val
}
}
if err := s.Err(); err != nil {
return c, err
}
return c, nil
}
func splitPath(v string) []string {
var out []string
for _, p := range strings.Split(v, "|") {
p = strings.TrimSpace(strings.Trim(p, "\\\""))
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return []string{"."}
}
return out
}
func parseBool(v string, d bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
}
return d
}
func (c Config) Validate() error {
if len(c.Path) == 0 {
return fmt.Errorf("path is empty")
}
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("invalid port")
}
return nil
}
+73 -12
View File
@@ -1,8 +1,11 @@
package storage package storage
import ( import (
"errors"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
type Storage interface { type Storage interface {
@@ -17,24 +20,82 @@ type Storage interface {
type Local struct{ Root string } type Local struct{ Root string }
func (s Local) real(path string) string { return filepath.Join(s.Root, filepath.Clean("/"+path)) } func (s Local) real(path string) (string, error) {
func (s Local) Stat(path string) (os.FileInfo, error) { return os.Stat(s.real(path)) } root, err := filepath.Abs(s.Root)
func (s Local) Open(path string) (*os.File, error) { return os.Open(s.real(path)) } if err != nil {
return "", err
}
p := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator))
full := filepath.Join(root, p)
rel, err := filepath.Rel(root, full)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("path escapes storage root")
}
return full, nil
}
func (s Local) Stat(path string) (os.FileInfo, error) {
p, e := s.real(path)
if e != nil {
return nil, e
}
return os.Stat(p)
}
func (s Local) Open(path string) (*os.File, error) {
p, e := s.real(path)
if e != nil {
return nil, e
}
return os.Open(p)
}
func (s Local) Create(path string) (*os.File, error) { func (s Local) Create(path string) (*os.File, error) {
p := s.real(path) p, e := s.real(path)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { if e != nil {
return nil, err return nil, e
}
if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil {
return nil, e
} }
return os.Create(p) return os.Create(p)
} }
func (s Local) Remove(path string) error { return os.RemoveAll(s.real(path)) } func (s Local) Remove(path string) error {
func (s Local) Rename(a, b string) error { return os.Rename(s.real(a), s.real(b)) } p, e := s.real(path)
func (s Local) MkdirAll(path string) error { return os.MkdirAll(s.real(path), 0755) } if e != nil {
return e
}
return os.RemoveAll(p)
}
func (s Local) Rename(a, b string) error {
pa, e := s.real(a)
if e != nil {
return e
}
pb, e := s.real(b)
if e != nil {
return e
}
return os.Rename(pa, pb)
}
func (s Local) MkdirAll(path string) error {
p, e := s.real(path)
if e != nil {
return e
}
return os.MkdirAll(p, 0755)
}
func (s Local) List(path string) ([]os.FileInfo, error) { func (s Local) List(path string) ([]os.FileInfo, error) {
f, err := os.Open(s.real(path)) p, e := s.real(path)
if err != nil { if e != nil {
return nil, err return nil, e
}
f, e := os.Open(p)
if e != nil {
return nil, e
} }
defer f.Close() defer f.Close()
return f.Readdir(-1) return f.Readdir(-1)
} }
var _ fs.FS