feat: implement legacy config auth storage and core api
This commit is contained in:
@@ -1,11 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"chfs-ng/internal/storage"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"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 {
|
||||
mux := http.NewServeMux()
|
||||
@@ -15,12 +37,192 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/chfs/rmfiles", s.remove)
|
||||
mux.HandleFunc("/chfs/exist", s.exist)
|
||||
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
|
||||
}
|
||||
func notImpl(w http.ResponseWriter) { http.Error(w, "not implemented", http.StatusNotImplemented) }
|
||||
func (s *Server) files(w http.ResponseWriter, r *http.Request) { notImpl(w) }
|
||||
func (s *Server) newdir(w http.ResponseWriter, r *http.Request) { notImpl(w) }
|
||||
func (s *Server) rename(w http.ResponseWriter, r *http.Request) { notImpl(w) }
|
||||
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) }
|
||||
func account(r *http.Request, s *Server) *auth.Account {
|
||||
if c, e := r.Cookie("chfs_token"); e == nil {
|
||||
if a := s.Sessions[c.Value]; a != nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PermMask uint8
|
||||
|
||||
const (
|
||||
@@ -12,8 +19,116 @@ const (
|
||||
func (p PermMask) HasRead() bool { return p&Read != 0 }
|
||||
func (p PermMask) HasWrite() bool { return p&Write != 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 {
|
||||
Username, Password string
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
@@ -17,24 +20,82 @@ type Storage interface {
|
||||
|
||||
type Local struct{ Root string }
|
||||
|
||||
func (s Local) real(path string) string { return filepath.Join(s.Root, filepath.Clean("/"+path)) }
|
||||
func (s Local) Stat(path string) (os.FileInfo, error) { return os.Stat(s.real(path)) }
|
||||
func (s Local) Open(path string) (*os.File, error) { return os.Open(s.real(path)) }
|
||||
func (s Local) real(path string) (string, error) {
|
||||
root, err := filepath.Abs(s.Root)
|
||||
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) {
|
||||
p := s.real(path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
return nil, err
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return os.Create(p)
|
||||
}
|
||||
func (s Local) Remove(path string) error { return os.RemoveAll(s.real(path)) }
|
||||
func (s Local) Rename(a, b string) error { return os.Rename(s.real(a), s.real(b)) }
|
||||
func (s Local) MkdirAll(path string) error { return os.MkdirAll(s.real(path), 0755) }
|
||||
func (s Local) Remove(path string) error {
|
||||
p, e := s.real(path)
|
||||
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) {
|
||||
f, err := os.Open(s.real(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
f, e := os.Open(p)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Readdir(-1)
|
||||
}
|
||||
|
||||
var _ fs.FS
|
||||
|
||||
Reference in New Issue
Block a user