Compare commits

..
4 Commits
8 changed files with 105 additions and 31 deletions
+24 -1
View File
@@ -1,6 +1,8 @@
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"log"
"net/http"
@@ -120,10 +122,31 @@ func main() {
} 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 {
@@ -131,7 +154,7 @@ func main() {
}
}
}
srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, WebDAVRoot: root, Allow: cfg.Allow, 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}
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, 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}
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 != "" {
+2 -1
View File
@@ -5,7 +5,8 @@ image.preview=true
file.remove=3
Known additional keys from binary/config ecosystem:
session.timeout
session.timeout # login session lifetime in minutes; default 30
jwt.secret # optional signing secret; if omitted a random secret is generated per process
folder.download
folder.leaf.download
rule.default
+3
View File
@@ -18,6 +18,9 @@ func TestRootAndAPIDoc(t *testing.T) {
if r.Code != 200 || !strings.Contains(r.Body.String(), "<title>My Files</title>") || !strings.Contains(r.Body.String(), "<strong>Hello</strong>") {
t.Fatalf("root: %d %s", r.Code, r.Body.String())
}
if got := r.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("root cache-control=%q", got)
}
r = httptest.NewRecorder()
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
+24 -17
View File
@@ -42,6 +42,7 @@ type Server struct {
SessionExpiry map[string]time.Time
SessionMu sync.RWMutex
SessionTimeout int
JWTSecret string
WebDAVRoot string
WebDAVFS webdav.FileSystem
Allow string
@@ -113,7 +114,7 @@ func account(r *http.Request, s *Server) *auth.Account {
if err != nil {
return s.Auth.Guest
}
username, err := auth.GetAccFromToken(c.Value)
username, err := auth.GetAccFromToken(c.Value, s.JWTSecret)
if err != nil {
return s.Auth.Guest
}
@@ -145,11 +146,20 @@ func clientIPString(r *http.Request) string {
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Cookie")
json.NewEncoder(w).Encode(v)
}
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Cookie")
}
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "Cookie")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
@@ -510,19 +520,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
noStore(w)
if r.Method == http.MethodDelete {
jwtValue, _ := r.Cookie("JWT")
userValue, _ := r.Cookie("user")
jwt := ""
user := ""
if jwtValue != nil {
jwt = jwtValue.Value
}
if userValue != nil {
user = userValue.Value
}
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: jwt, Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: "user", Value: user, Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: "user", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), SameSite: http.SameSiteLaxMode})
w.WriteHeader(http.StatusNoContent)
return
}
@@ -540,14 +541,19 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusNotFound, "user or password is wrong")
return
}
token, err := auth.NewToken(a.Username, s.SessionTimeout)
timeout := s.SessionTimeout
if timeout <= 0 {
timeout = 30
}
token, err := auth.NewToken(a.Username, timeout, s.JWTSecret)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to create token")
return
}
expires := time.Now().Add(365 * 24 * time.Hour)
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", Expires: expires, HttpOnly: true})
http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", Expires: expires})
expires := time.Now().Add(time.Duration(timeout) * time.Minute)
secure := r.TLS != nil
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", MaxAge: timeout * 60, Expires: expires, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", MaxAge: timeout * 60, Expires: expires, Secure: secure, SameSite: http.SameSiteLaxMode})
w.WriteHeader(http.StatusCreated)
}
@@ -1133,6 +1139,7 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
title := s.HTMLTitle
if title == "" {
title = "CuteHttpFileServer"
+5 -3
View File
@@ -46,7 +46,7 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
<script>
const $=id=>document.getElementById(id);let current="",me=null,currentMask="",navigationRoot="";
async function api(url,opt={}){const r=await fetch(url,{cache:"no-store",...opt});if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};const e=Error(t);e.status=r.status;throw e}return r.status===204?null:r.json()}
async function api(url,opt={}){const r=await fetch(url,{credentials:"same-origin",cache:"no-store",...opt});if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};const e=Error(t);e.status=r.status;throw e}return r.status===204?null:r.json()}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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||"";navigationRoot=me.home||"";const h=new URLSearchParams(location.hash.slice(1));const shared=h.get("folder");if(shared!==null)current=shared;await refresh()}
@@ -73,8 +73,10 @@ function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.qu
function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!(f.mask||'').includes('W'))return;e.preventDefault();if(target===current||target===""||[...selected].some(p=>p===target||target.startsWith(p+'/')))return;e.dataTransfer.dropEffect="move";e.currentTarget.classList.add('drop-target')}
function folderDragLeave(e){e.currentTarget.classList.remove('drop-target')}
async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');await runOperation('移动 '+paths.length+' 项',async()=>{let done=0,failed=[],renamed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop());done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('<h2>移动完成</h2><div class="notice">'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast('已移动 '+done+' 项')})}
function handleItemClick(e,p){if(window.matchMedia&&window.matchMedia('(max-width:700px)').matches){activateItem(p);return}selectItem(p,true,e)}
function handleItemClick(e,p){if(!me.username){guestItemActions(p);return}if(window.matchMedia&&window.matchMedia('(max-width:700px)').matches){activateItem(p);return}selectItem(p,true,e)}
function activateItem(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)}
function guestItemActions(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f||!(f.mask||"").includes("R")){toast("当前项目没有访问权限",true);return}if(f.dir){openDir(p);return}const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;let html='<h2>'+esc(f.name)+'</h2><div class="notice">访客可以直接下载或复制分享链接。</div><div class="row" style="justify-content:flex-start"><button onclick="location.href='+JSON.stringify("/chfs/shared/"+encodeURI(p))+'">下载</button><button class="ghost" onclick="guestCopyShare('+JSON.stringify(url)+')">复制分享链接</button></div>';show(html)}
async function guestCopyShare(url){try{await navigator.clipboard.writeText(url);toast("分享链接已复制");closeModal()}catch{show('<h2>分享链接</h2><div class="field"><input value="'+esc(url)+'" readonly></div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}}
function toggleTile(p){selectItem(p,!selected.has(p))}
function openSelected(){if(selected.size!==1)return;const p=[...selected][0];if(searchMode){const f=searchItems.find(x=>x.path===p);if(f)openSearchResult(f);return}const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)}
function toggleAll(v){const source=searchMode?searchItems:lastItems;source.forEach(f=>{const p=searchMode?f.path:(current?current+"/":"")+f.name;if(v)selected.add(p);else selected.delete(p)});searchMode?updateSelection():render(lastItems)}
@@ -166,7 +168,7 @@ async function previewFile(p){const n=p.split('/').pop(),ext=n.toLowerCase().spl
function changePassword(){show('<h2>修改密码</h2><div class="field"><input id="cp" type="password" placeholder="当前密码"><input id="npw" type="password" placeholder="新密码"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="savePassword()">保存</button></div>')}
async function savePassword(){try{await api("/chfs/password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({currentPassword:$("cp").value,password:$("npw").value})});toast("密码已更新");closeModal()}catch(e){fail(e.message)}}
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){fail(e.message)}}
async function login(){try{await fetch("/chfs/session",{method:"POST",credentials:"same-origin",cache:"no-store",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){fail(e.message)}}
async function logout(){try{await api("/chfs/session",{method:"DELETE"})}finally{me={username:"",home:"",admin:false,managed:false};current="";navigationRoot="";selected.clear();updateUser();await refresh()}}
function toast(message,error=false){const host=$("toastHost"),el=document.createElement("div");el.className='toast'+(error?' error':'');el.textContent=message;host.appendChild(el);setTimeout(()=>el.remove(),2800)}
function fail(message){toast(message,true)}
+16 -8
View File
@@ -197,16 +197,24 @@ type jwtClaims struct {
jwt.StandardClaims
}
func NewToken(username string, _ int) (string, error) {
// CHFS v4 keeps the JWT itself valid for one year. session.timeout is
// retained as a compatibility configuration field, but it does not alter
// the JWT exp claim in the original implementation.
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}}
func NewToken(username string, timeoutMinutes int, secrets ...string) (string, error) {
if timeoutMinutes <= 0 {
timeoutMinutes = 30
}
secret := jwtSecret
if len(secrets) > 0 && secrets[0] != "" {
secret = secrets[0]
}
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(time.Duration(timeoutMinutes) * time.Minute).Unix()}}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(jwtSecret))
return token.SignedString([]byte(secret))
}
func GetAccFromToken(raw string) (string, error) {
func GetAccFromToken(raw string, secrets ...string) (string, error) {
secret := jwtSecret
if len(secrets) > 0 && secrets[0] != "" {
secret = secrets[0]
}
if raw == "" {
return "", fmt.Errorf("empty token")
}
@@ -215,7 +223,7 @@ func GetAccFromToken(raw string) (string, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg())
}
return []byte(jwtSecret), nil
return []byte(secret), nil
})
if err != nil || token == nil || !token.Valid {
if err == nil {
+28 -1
View File
@@ -1,6 +1,33 @@
package auth
import "testing"
import (
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v4"
)
func TestTokenUsesConfiguredSecretAndTimeout(t *testing.T) {
const secret = "test-secret"
before := time.Now().Add(2 * time.Minute).Unix()
raw, err := NewToken("alice", 2, secret)
if err != nil {
t.Fatal(err)
}
var claims jwtClaims
token, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
t.Fatalf("token should validate with configured secret: %v", err)
}
if claims.ExpiresAt < before-1 || claims.ExpiresAt > before+1 {
t.Fatalf("unexpected expiration: %v", claims.ExpiresAt)
}
if _, err := GetAccFromToken(raw, "wrong-secret"); err == nil {
t.Fatal("token validated with wrong secret")
}
}
func TestMatchPath(t *testing.T) {
cases := []struct {
+3
View File
@@ -29,6 +29,7 @@ type Config struct {
ImagePreview bool
FolderDownload string
SessionTimeout int
JWTSecret string
HTMLTitle string
HTMLNotice string
UserFile string
@@ -122,6 +123,8 @@ func Load(path string) (Config, error) {
if n, e := strconv.Atoi(val); e == nil && n > 0 {
c.SessionTimeout = n
}
case "jwt.secret":
c.JWTSecret = val
case "html.title":
c.HTMLTitle = val
case "html.notice":