feat: add web ui and dynamic private users
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -30,6 +31,9 @@ import (
|
||||
"github.com/emersion/go-webdav"
|
||||
)
|
||||
|
||||
//go:embed web/index.html
|
||||
var webIndex []byte
|
||||
|
||||
type Server struct {
|
||||
Store storage.Storage
|
||||
Auth *auth.Manager
|
||||
@@ -47,6 +51,8 @@ type Server struct {
|
||||
HTMLTitle string
|
||||
HTMLNotice string
|
||||
Log *opslog.Logger
|
||||
UserStore *auth.UserStore
|
||||
UserHome string
|
||||
}
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
@@ -69,6 +75,8 @@ 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/me", s.me)
|
||||
mux.HandleFunc("/chfs/users", s.users)
|
||||
mux.HandleFunc("/chfs/newtext", s.newtext)
|
||||
mux.HandleFunc("/chfs/updatetext", s.updatetext)
|
||||
mux.HandleFunc("/chfs/upload", s.upload)
|
||||
@@ -450,6 +458,82 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
a := account(r, s)
|
||||
if a == nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home))})
|
||||
}
|
||||
|
||||
func (s *Server) users(w http.ResponseWriter, r *http.Request) {
|
||||
a := account(r, s)
|
||||
if a == nil || !a.Admin {
|
||||
writeJSONError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.UserStore == nil {
|
||||
writeJSONError(w, http.StatusNotImplemented, "user management disabled")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
out := make([]map[string]any, 0, len(s.Auth.Accounts))
|
||||
for _, u := range s.Auth.Accounts {
|
||||
if u.Username == "" || u == s.Auth.Guest {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{"username": u.Username, "home": u.Home, "admin": u.Admin, "managed": u.PasswordHash != ""})
|
||||
}
|
||||
writeJSON(w, map[string]any{"users": out})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
home := path.Join(strings.Trim(s.UserHome, "/"), req.Username)
|
||||
if unsafePath(home) {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid username")
|
||||
return
|
||||
}
|
||||
u, err := s.UserStore.Add(s.Auth, req.Username, req.Password, home)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.Store.MkdirAll(u.Home); err != nil {
|
||||
_ = s.UserStore.Delete(s.Auth, u.Username)
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.logOp(r, fmt.Sprintf("created user '%s'", u.Username))
|
||||
writeJSON(w, map[string]any{"username": u.Username, "home": u.Home})
|
||||
case http.MethodDelete:
|
||||
username := r.URL.Query().Get("username")
|
||||
if err := auth.ValidateUsername(username); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid username")
|
||||
return
|
||||
}
|
||||
if username == a.Username {
|
||||
writeJSONError(w, http.StatusBadRequest, "cannot delete current admin")
|
||||
return
|
||||
}
|
||||
if err := s.UserStore.Delete(s.Auth, username); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.logOp(r, fmt.Sprintf("deleted user '%s'", username))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) newtext(w http.ResponseWriter, r *http.Request) {
|
||||
a := account(r, s)
|
||||
rawDir, rawTitle, rawFilename := r.FormValue("filepath"), r.FormValue("title"), r.FormValue("filename")
|
||||
@@ -737,7 +821,7 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/")
|
||||
if unsafePath(p) {
|
||||
if unsafePath(p) || !account(r, s).MaskFor(p).HasRead() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -842,8 +926,10 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
notice = html.EscapeString(notice)
|
||||
}
|
||||
page := bytes.ReplaceAll(webIndex, []byte("__CHFS_TITLE__"), []byte(html.EscapeString(title)))
|
||||
page = bytes.ReplaceAll(page, []byte("__CHFS_NOTICE__"), []byte(notice))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body><main><h1>%s</h1>%s<p><a href="/chfs/files?filepath=">Open file API</a> · <a href="/asset/api.html">API documentation</a></p></main></body></html>`, html.EscapeString(title), html.EscapeString(title), notice)
|
||||
_, _ = w.Write(page)
|
||||
}
|
||||
|
||||
func (s *Server) apiDoc(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -852,7 +938,7 @@ func (s *Server) apiDoc(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CHFS API</title></head><body><h1>CHFS API</h1><p>Compatibility API implemented by chfs-ng.</p><ul><li>/chfs/files</li><li>/chfs/session</li><li>/chfs/upload</li><li>/chfs/newdir</li><li>/chfs/rename</li><li>/chfs/rmfiles</li><li>/chfs/search</li><li>/chfs/downloaddir/<path></li><li>/chfs/shared/<path></li><li>/chfs/preview</li><li>/webdav</li></ul></body></html>`
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CHFS API</title></head><body><h1>CHFS API</h1><p>Compatibility API implemented by chfs-ng.</p><ul><li>/chfs/files</li><li>/chfs/session</li><li>/chfs/me</li><li>/chfs/users (admin)</li><li>/chfs/upload</li><li>/chfs/newdir</li><li>/chfs/rename</li><li>/chfs/rmfiles</li><li>/chfs/search</li><li>/chfs/downloaddir/<path></li><li>/chfs/shared/<path></li><li>/chfs/preview</li><li>/webdav</li></ul></body></html>`
|
||||
_, _ = w.Write([]byte(doc))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestUserManagementAndPrivateHome(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usersFile := filepath.Join(t.TempDir(), "users.json")
|
||||
mgr := &auth.Manager{
|
||||
Accounts: map[string]*auth.Account{
|
||||
"admin": {Username: "admin", Password: "secret", Admin: true, Mask: auth.Read | auth.Write | auth.Delete},
|
||||
},
|
||||
Guest: &auth.Account{Mask: auth.None},
|
||||
}
|
||||
s := &Server{Store: storage.Local{Root: root}, Auth: mgr, UserStore: &auth.UserStore{Path: usersFile}, UserHome: "users"}
|
||||
h := s.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=admin&pwd=secret"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("admin login: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
cookie := rr.Result().Cookies()[0]
|
||||
|
||||
body := []byte(`{"username":"alice","password":"alice-pass"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/chfs/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("create user: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "users", "alice")); err != nil {
|
||||
t.Fatalf("home not created: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("dynamic user login: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
token, err := auth.NewToken("alice", 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "currentMask") || !strings.Contains(rr.Body.String(), "\"currentMask\":\"\"") {
|
||||
t.Fatalf("private root access: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=users/alice", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"RWD\"") {
|
||||
t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var saved []map[string]any
|
||||
data, err := os.ReadFile(usersFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &saved); err != nil || len(saved) != 1 {
|
||||
t.Fatalf("user persistence: %v %#v", err, saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>__CHFS_TITLE__</title>
|
||||
<style>
|
||||
:root{font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}
|
||||
*{box-sizing:border-box}body{margin:0}.top{height:64px;background:#fff;border-bottom:1px solid #e5e9f2;display:flex;align-items:center;padding:0 22px;gap:18px;position:sticky;top:0;z-index:2}.brand{font-weight:750;font-size:20px}.path{color:#718096;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.spacer{flex:1}
|
||||
button,input{font:inherit}button{border:0;border-radius:9px;padding:9px 13px;cursor:pointer;background:#2563eb;color:#fff}button.ghost{background:#eef2f7;color:#334155}button.danger{background:#dc2626}
|
||||
main{max-width:1180px;margin:26px auto;padding:0 18px}.toolbar{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:18px}.toolbar input{min-width:220px;flex:1;padding:10px 12px;border:1px solid #d9dfeb;border-radius:9px;background:#fff}
|
||||
.card{background:#fff;border:1px solid #e4e8f0;border-radius:14px;overflow:hidden;box-shadow:0 4px 18px rgba(20,30,50,.04)}
|
||||
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 15px;border-bottom:1px solid #edf0f5}th{font-size:13px;color:#7b8798;background:#fafbfc}tr:last-child td{border-bottom:0}.name{display:flex;align-items:center;gap:10px}.name a{color:#172033;text-decoration:none;font-weight:550}.meta{color:#8792a5;font-size:13px}.actions{display:flex;gap:7px;justify-content:flex-end}.empty{padding:45px;text-align:center;color:#8a94a6}
|
||||
.modal{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:center;justify-content:center;padding:18px;z-index:4}.modal.show{display:flex}.dialog{width:min(480px,100%);background:#fff;border-radius:15px;padding:22px}.dialog h2{margin-top:0}.field{display:grid;gap:7px;margin:13px 0}.field input{padding:10px 11px;border:1px solid #d7ddea;border-radius:9px}.row{display:flex;gap:9px;justify-content:flex-end;margin-top:18px}
|
||||
.login{max-width:390px;margin:10vh auto}.notice{padding:12px 14px;background:#eff6ff;border-radius:10px;color:#315b9b;margin-bottom:14px}
|
||||
@media(max-width:700px){th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){display:none}.top{padding:0 13px}.path{max-width:40vw}.actions button{padding:7px 9px}.toolbar input{min-width:160px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="brand" id="brand">CHFS</div><div class="path" id="crumb">/</div><div class="spacer"></div>
|
||||
<span id="who" class="meta"></span><button class="ghost" id="loginBtn" onclick="openLogin()">登录</button>
|
||||
<button class="ghost" id="logoutBtn" onclick="logout()" hidden>退出</button>
|
||||
</header>
|
||||
<main>
|
||||
<div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
|
||||
<div class="toolbar">
|
||||
<button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button>
|
||||
<button onclick="newFolder()">新建文件夹</button><button onclick="newText()">新建文本</button>
|
||||
<label><button>上传</button><input id="upload" type="file" multiple hidden></label>
|
||||
<input id="search" placeholder="搜索文件名,回车执行">
|
||||
<button id="usersBtn" onclick="showUsers()" hidden>用户管理</button>
|
||||
</div>
|
||||
<div class="card"><table><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th></th></tr></thead><tbody id="files"></tbody></table></div>
|
||||
</main>
|
||||
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);let current="",me=null;
|
||||
async function api(url,opt={}){const r=await fetch(url,opt);if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};throw Error(t)}return r.status===204?null:r.json()}
|
||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]))}
|
||||
function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"}
|
||||
async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";await refresh()}
|
||||
function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin}
|
||||
async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);render(data.files||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}}
|
||||
function render(items){const body=$("files");body.innerHTML="";if(!items.length){body.innerHTML='<tr><td colspan="4" class="empty">这里还没有文件</td></tr>';return}for(const f of items){const p=current?current+"/"+f.name:f.name;const row=document.createElement("tr");const icon=f.dir?"📁":"📄";row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir?'<a href="#" onclick="openDir('+JSON.stringify(p)+');return false">'+esc(f.name)+'</a>':'<span>'+esc(f.name)+'</span>')+'</div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td><div class="actions">'+(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>')+'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button><button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button></div></td>';body.appendChild(row)}}
|
||||
function openDir(p){current=p;refresh()}function goUp(){if(!current)return;const root=me.home||"";if(current===root)return;let p=current.split("/");p.pop();let n=p.join("/");if(root&&!(n===root||n.startsWith(root+"/")))n=root;current=n;refresh()}
|
||||
function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
|
||||
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}
|
||||
async function renameItem(p){const name=p.split("/").pop();const n=prompt("新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});refresh()}catch(e){alert(e.message)}}
|
||||
async function removeItem(p){if(!confirm("确定删除 "+p+"?"))return;try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}}
|
||||
async function newFolder(){const n=prompt("文件夹名称");if(!n)return;const p=current?current+"/"+n:n;try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}}
|
||||
async function newText(){const n=prompt("文件名","new.txt");if(!n)return;const text=prompt("文本内容","");try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});refresh()}catch(e){alert(e.message)}}
|
||||
$("upload").addEventListener("change",async e=>{const fd=new FormData();fd.append("folder",current);for(const f of e.target.files)fd.append("file",f);try{await api("/chfs/upload",{method:"POST",body:fd});refresh()}catch(x){alert(x.message)}e.target.value=""})
|
||||
$("search").addEventListener("keydown",async e=>{if(e.key!=="Enter")return;const s=e.target.value.trim();if(!s){refresh();return}try{const d=await api("/chfs/search?str="+encodeURIComponent(s));renderSearch(d.files||[])}catch(x){alert(x.message)}})
|
||||
function renderSearch(items){$("crumb").textContent="搜索结果";const body=$("files");body.innerHTML="";for(const f of items){const row=document.createElement("tr");row.innerHTML='<td><div class="name">🔎 <a href="#" onclick="openDir('+JSON.stringify(f.dir?f.path:f.path.split("/").slice(0,-1).join("/")).replace(/^"/,'\\"')+');return false">'+esc(f.path)+'</a></div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td></td>';body.appendChild(row)}}
|
||||
function openLogin(){show('<h2>登录</h2><div class="field"><label>用户名</label><input id="u" autocomplete="username"></div><div class="field"><label>密码</label><input id="p" type="password" autocomplete="current-password"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="login()">登录</button></div>')}
|
||||
async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({user:$("u").value,pwd:$("p").value})}).then(async r=>{if(!r.ok)throw Error((await r.json()).error||"登录失败")});closeModal();await init()}catch(e){alert(e.message)}}
|
||||
async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()}
|
||||
function show(html){$("dialog").innerHTML=html;$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show")}
|
||||
$("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
|
||||
async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<p><b>'+esc(u.username)+'</b> · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</p>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}}
|
||||
async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){alert(e.message)}}
|
||||
async function deleteUser(u){if(!confirm("删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){alert(e.message)}}
|
||||
init();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1,6 +1,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -43,8 +45,11 @@ type PathRule struct {
|
||||
}
|
||||
type Account struct {
|
||||
Username, Password string
|
||||
PasswordHash string
|
||||
Mask PermMask
|
||||
Rules []PathRule
|
||||
Home string
|
||||
Admin bool
|
||||
}
|
||||
type Manager struct {
|
||||
Accounts map[string]*Account
|
||||
@@ -80,7 +85,7 @@ func ParseRules(values []string) (*Manager, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) Authenticate(user, pwd string) *Account {
|
||||
if a, ok := m.Accounts[user]; ok && a.Password == pwd {
|
||||
if a, ok := m.Accounts[user]; ok && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) {
|
||||
return a
|
||||
}
|
||||
if user == "" && pwd == "" {
|
||||
@@ -88,6 +93,11 @@ func (m *Manager) Authenticate(user, pwd string) *Account {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func PasswordHash(password string) string {
|
||||
h := sha256.Sum256([]byte(password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (a *Account) MaskFor(path string) PermMask {
|
||||
return a.maskFor(path, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type UserStore struct {
|
||||
Path string
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
||||
type userRecord struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"passwordHash"`
|
||||
Home string `json:"home"`
|
||||
}
|
||||
|
||||
var validUsername = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$")
|
||||
|
||||
func ValidateUsername(username string) error {
|
||||
if !validUsername.MatchString(username) {
|
||||
return fmt.Errorf("invalid username")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UserStore) Load(m *Manager, homeRoot string) error {
|
||||
if u.Path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(u.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var records []userRecord
|
||||
if err := json.Unmarshal(data, &records); err != nil {
|
||||
return fmt.Errorf("invalid user store: %w", err)
|
||||
}
|
||||
for _, rec := range records {
|
||||
if err := ValidateUsername(rec.Username); err != nil || rec.Password == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := m.Accounts[rec.Username]; exists {
|
||||
continue
|
||||
}
|
||||
home := filepath.ToSlash(strings.Trim(strings.TrimSpace(rec.Home), "/"))
|
||||
if home == "" {
|
||||
home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username))
|
||||
}
|
||||
a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home}
|
||||
a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete})
|
||||
m.Accounts[rec.Username] = a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UserStore) Save(m *Manager) error {
|
||||
if u.Path == "" {
|
||||
return nil
|
||||
}
|
||||
u.Mu.Lock()
|
||||
defer u.Mu.Unlock()
|
||||
records := make([]userRecord, 0)
|
||||
for _, a := range m.Accounts {
|
||||
if a.PasswordHash == "" || a.Home == "" || a.Admin {
|
||||
continue
|
||||
}
|
||||
records = append(records, userRecord{Username: a.Username, Password: a.PasswordHash, Home: a.Home})
|
||||
}
|
||||
data, err := json.MarshalIndent(records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(u.Path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp := u.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, u.Path)
|
||||
}
|
||||
|
||||
func (u *UserStore) Add(m *Manager, username, password, home string) (*Account, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil, fmt.Errorf("password required")
|
||||
}
|
||||
if _, ok := m.Accounts[username]; ok {
|
||||
return nil, fmt.Errorf("user already exists")
|
||||
}
|
||||
home = strings.Trim(home, "/")
|
||||
a := &Account{Username: username, PasswordHash: PasswordHash(password), Mask: None, Home: home}
|
||||
a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete})
|
||||
m.Accounts[username] = a
|
||||
return a, u.Save(m)
|
||||
}
|
||||
|
||||
func (u *UserStore) Delete(m *Manager, username string) error {
|
||||
a, ok := m.Accounts[username]
|
||||
if !ok || a.Admin || a.PasswordHash == "" {
|
||||
return fmt.Errorf("managed user not found")
|
||||
}
|
||||
delete(m.Accounts, username)
|
||||
return u.Save(m)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type AccountConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
Role string
|
||||
Default string
|
||||
None []string
|
||||
Read []string
|
||||
@@ -30,13 +31,15 @@ type Config struct {
|
||||
SessionTimeout int
|
||||
HTMLTitle string
|
||||
HTMLNotice string
|
||||
UserFile string
|
||||
UserHome string
|
||||
SSLCert string
|
||||
SSLKey string
|
||||
Accounts []AccountConfig
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30}
|
||||
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"}
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
@@ -73,6 +76,8 @@ func Load(path string) (Config, error) {
|
||||
switch strings.ToLower(key) {
|
||||
case "password":
|
||||
section.Password = val
|
||||
case "role":
|
||||
section.Role = strings.ToLower(val)
|
||||
case "rule.default":
|
||||
section.Default = val
|
||||
case "rule.none":
|
||||
@@ -121,6 +126,10 @@ func Load(path string) (Config, error) {
|
||||
c.HTMLTitle = val
|
||||
case "html.notice":
|
||||
c.HTMLNotice = val
|
||||
case "user.file":
|
||||
c.UserFile = val
|
||||
case "user.home":
|
||||
c.UserHome = strings.Trim(val, "/")
|
||||
case "ssl.cert":
|
||||
c.SSLCert = val
|
||||
case "ssl.key":
|
||||
|
||||
Reference in New Issue
Block a user