Compare commits
4
Commits
beeee8191b
...
7b2c5f444b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b2c5f444b | ||
|
|
f8b5b585c9 | ||
|
|
c37161db31 | ||
|
|
2c8531d9b9 |
+24
-1
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -120,10 +122,31 @@ func main() {
|
|||||||
} else if !filepath.IsAbs(userFile) {
|
} else if !filepath.IsAbs(userFile) {
|
||||||
userFile = filepath.Join(root, userFile)
|
userFile = filepath.Join(root, userFile)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(userFile), 0700); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
users := &auth.UserStore{Path: userFile}
|
users := &auth.UserStore{Path: userFile}
|
||||||
if err := users.Load(mgr, cfg.UserHome); err != nil {
|
if err := users.Load(mgr, cfg.UserHome); err != nil {
|
||||||
log.Fatal(err)
|
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 {
|
for _, a := range mgr.Accounts {
|
||||||
if a.Home != "" {
|
if a.Home != "" {
|
||||||
if err := store.MkdirAll(a.Home); err != nil {
|
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)
|
addr := ":" + itoa(cfg.Port)
|
||||||
log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove)
|
log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove)
|
||||||
if cfg.SSLCert != "" && cfg.SSLKey != "" {
|
if cfg.SSLCert != "" && cfg.SSLKey != "" {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ image.preview=true
|
|||||||
file.remove=3
|
file.remove=3
|
||||||
|
|
||||||
Known additional keys from binary/config ecosystem:
|
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.download
|
||||||
folder.leaf.download
|
folder.leaf.download
|
||||||
rule.default
|
rule.default
|
||||||
|
|||||||
@@ -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>") {
|
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())
|
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()
|
r = httptest.NewRecorder()
|
||||||
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
|
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
|
||||||
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
|
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
|
||||||
|
|||||||
+24
-17
@@ -42,6 +42,7 @@ type Server struct {
|
|||||||
SessionExpiry map[string]time.Time
|
SessionExpiry map[string]time.Time
|
||||||
SessionMu sync.RWMutex
|
SessionMu sync.RWMutex
|
||||||
SessionTimeout int
|
SessionTimeout int
|
||||||
|
JWTSecret string
|
||||||
WebDAVRoot string
|
WebDAVRoot string
|
||||||
WebDAVFS webdav.FileSystem
|
WebDAVFS webdav.FileSystem
|
||||||
Allow string
|
Allow string
|
||||||
@@ -113,7 +114,7 @@ func account(r *http.Request, s *Server) *auth.Account {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return s.Auth.Guest
|
return s.Auth.Guest
|
||||||
}
|
}
|
||||||
username, err := auth.GetAccFromToken(c.Value)
|
username, err := auth.GetAccFromToken(c.Value, s.JWTSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.Auth.Guest
|
return s.Auth.Guest
|
||||||
}
|
}
|
||||||
@@ -145,11 +146,20 @@ func clientIPString(r *http.Request) string {
|
|||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Vary", "Cookie")
|
||||||
json.NewEncoder(w).Encode(v)
|
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) {
|
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Vary", "Cookie")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
_ = 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) {
|
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
||||||
|
noStore(w)
|
||||||
if r.Method == http.MethodDelete {
|
if r.Method == http.MethodDelete {
|
||||||
jwtValue, _ := r.Cookie("JWT")
|
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||||
userValue, _ := r.Cookie("user")
|
http.SetCookie(w, &http.Cookie{Name: "user", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), SameSite: http.SameSiteLaxMode})
|
||||||
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})
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -540,14 +541,19 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONError(w, http.StatusNotFound, "user or password is wrong")
|
writeJSONError(w, http.StatusNotFound, "user or password is wrong")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeJSONError(w, http.StatusInternalServerError, "failed to create token")
|
writeJSONError(w, http.StatusInternalServerError, "failed to create token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expires := time.Now().Add(365 * 24 * time.Hour)
|
expires := time.Now().Add(time.Duration(timeout) * time.Minute)
|
||||||
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", Expires: expires, HttpOnly: true})
|
secure := r.TLS != nil
|
||||||
http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", Expires: expires})
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +1139,7 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
title := s.HTMLTitle
|
title := s.HTMLTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "CuteHttpFileServer"
|
title = "CuteHttpFileServer"
|
||||||
|
|||||||
@@ -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>
|
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
|
||||||
<script>
|
<script>
|
||||||
const $=id=>document.getElementById(id);let current="",me=null,currentMask="",navigationRoot="";
|
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=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]))}
|
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"}
|
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()}
|
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 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')}
|
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+' 项')})}
|
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 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 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 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)}
|
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>')}
|
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)}}
|
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>')}
|
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()}}
|
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 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)}
|
function fail(message){toast(message,true)}
|
||||||
|
|||||||
+16
-8
@@ -197,16 +197,24 @@ type jwtClaims struct {
|
|||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewToken(username string, _ int) (string, error) {
|
func NewToken(username string, timeoutMinutes int, secrets ...string) (string, error) {
|
||||||
// CHFS v4 keeps the JWT itself valid for one year. session.timeout is
|
if timeoutMinutes <= 0 {
|
||||||
// retained as a compatibility configuration field, but it does not alter
|
timeoutMinutes = 30
|
||||||
// the JWT exp claim in the original implementation.
|
}
|
||||||
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}}
|
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)
|
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 == "" {
|
if raw == "" {
|
||||||
return "", fmt.Errorf("empty token")
|
return "", fmt.Errorf("empty token")
|
||||||
}
|
}
|
||||||
@@ -215,7 +223,7 @@ func GetAccFromToken(raw string) (string, error) {
|
|||||||
if t.Method != jwt.SigningMethodHS256 {
|
if t.Method != jwt.SigningMethodHS256 {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg())
|
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 || token == nil || !token.Valid {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
package auth
|
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) {
|
func TestMatchPath(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Config struct {
|
|||||||
ImagePreview bool
|
ImagePreview bool
|
||||||
FolderDownload string
|
FolderDownload string
|
||||||
SessionTimeout int
|
SessionTimeout int
|
||||||
|
JWTSecret string
|
||||||
HTMLTitle string
|
HTMLTitle string
|
||||||
HTMLNotice string
|
HTMLNotice string
|
||||||
UserFile string
|
UserFile string
|
||||||
@@ -122,6 +123,8 @@ func Load(path string) (Config, error) {
|
|||||||
if n, e := strconv.Atoi(val); e == nil && n > 0 {
|
if n, e := strconv.Atoi(val); e == nil && n > 0 {
|
||||||
c.SessionTimeout = n
|
c.SessionTimeout = n
|
||||||
}
|
}
|
||||||
|
case "jwt.secret":
|
||||||
|
c.JWTSecret = val
|
||||||
case "html.title":
|
case "html.title":
|
||||||
c.HTMLTitle = val
|
c.HTMLTitle = val
|
||||||
case "html.notice":
|
case "html.notice":
|
||||||
|
|||||||
Reference in New Issue
Block a user