package main import ( "archive/zip" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" "mime" "mime/multipart" "net/http" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" ) const ( maxUploadBytes int64 = 2 << 30 maxFormBytes int64 = 64 << 10 maxBatchFormBytes int64 = 1 << 20 ) type fileServer struct { root string adminToken string secret []byte tmpl *template.Template } type entry struct { Name string Rel string IsDir bool Size string SizeBytes int64 ModTime string ModUnix int64 BrowseURL string DownloadURL string PreviewURL string DeleteURL string RenameURL string Selected bool } type sortLink struct { URL string Label string Arrow string Active bool } type batchItem struct { Name string IsDir bool } type crumb struct { Name string URL string Current bool } type pageData struct { Title string Root string RootURL string Path string Breadcrumbs []crumb ParentURL string SelfURL string Entries []entry DirCount int FileCount int AdminEnabled bool LoggedIn bool CSRF string UploadOpen bool MkdirOpen bool MaxUpload string Notice string NoticeError bool ConfirmName string ConfirmPath string DeleteIsDir bool ConfirmRename string ConfirmMany []batchItem ConfirmHasDir bool ConfirmDir string SelectAll bool SelectAllURL string SortName sortLink SortSize sortLink SortTime sortLink IsSearch bool SearchQuery string SearchBase string SearchBaseURL string SearchResults []entry SearchScanned int SearchTrunc bool } var ( errBadPath = errors.New("path escapes root") errInvalidName = errors.New("invalid file name") ) func main() { flag.Usage = func() { out := flag.CommandLine.Output() fmt.Fprintf(out, "用法: %s [选项]\n\n", os.Args[0]) fmt.Fprintln(out, "在网页上浏览并下载指定目录中的文件。") fmt.Fprintln(out, "\n选项:") flag.PrintDefaults() fmt.Fprintln(out, "\n示例:") fmt.Fprintf(out, " %s -dir ./docs\n", os.Args[0]) fmt.Fprintf(out, " %s -dir ./docs -addr 0.0.0.0:8080\n", os.Args[0]) fmt.Fprintln(out, "\n上传、新建文件夹、重命名和删除功能需设置环境变量 admin_token,未设置则自动禁用。") } dir := flag.String("dir", ".", "要展示的目录(默认当前目录)") addr := flag.String("addr", "127.0.0.1:8080", "监听地址;只填端口时记为 127.0.0.1:端口") flag.Parse() abs, err := filepath.Abs(*dir) if err != nil { log.Fatalf("无法解析目录 %q: %v", *dir, err) } root, err := filepath.EvalSymlinks(abs) if err != nil { log.Fatalf("目录不存在或不可访问 %q: %v", *dir, err) } info, err := os.Stat(root) if err != nil { log.Fatalf("无法访问目录 %q: %v", *dir, err) } if !info.IsDir() { log.Fatalf("%q 不是一个目录", *dir) } listen := strings.TrimSpace(*addr) if listen == "" { log.Fatal("监听地址不能为空") } if port, err := strconv.Atoi(listen); err == nil { listen = "127.0.0.1:" + strconv.Itoa(port) } adminToken := os.Getenv("admin_token") if strings.TrimSpace(adminToken) == "" { adminToken = "" } secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { log.Fatalf("无法初始化会话密钥: %v", err) } s := &fileServer{ root: root, adminToken: adminToken, secret: secret, tmpl: template.Must(template.New("index").Parse(pageHTML)), } mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/browse", s.handleBrowse) mux.HandleFunc("/download", s.handleDownload) mux.HandleFunc("/preview", s.handlePreview) mux.HandleFunc("/search", s.handleSearch) mux.HandleFunc("/upload", s.handleUpload) mux.HandleFunc("/mkdir", s.handleMkdir) mux.HandleFunc("/rename", s.handleRename) mux.HandleFunc("/delete", s.handleDelete) mux.HandleFunc("/transfer", s.handleTransfer) mux.HandleFunc("/batch", s.handleBatch) mux.HandleFunc("/batch-delete", s.handleBatchDelete) mux.HandleFunc("/logout", s.handleLogout) mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) shown := listen if strings.HasPrefix(shown, ":") { shown = "127.0.0.1" + shown } if strings.HasPrefix(shown, "0.0.0.0:") { shown = "127.0.0.1:" + strings.TrimPrefix(shown, "0.0.0.0:") } if strings.HasPrefix(shown, "[::]:") { shown = "[::1]:" + strings.TrimPrefix(shown, "[::]:") } fmt.Printf("文件浏览服务已启动: http://%s\n", shown) fmt.Printf("展示目录: %s\n", root) if adminToken == "" { fmt.Println("管理功能: 已禁用(未设置环境变量 admin_token)") } else { fmt.Println("管理功能: 已启用(上传、新建文件夹、重命名、删除需管理员令牌 admin_token)") } fmt.Println("按 Ctrl+C 停止服务。") srv := &http.Server{ Addr: listen, Handler: mux, ReadHeaderTimeout: 10 * time.Second, } if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("服务启动失败: %v", err) } } func (s *fileServer) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } s.serveListing(w, r, "") } func (s *fileServer) handleBrowse(w http.ResponseWriter, r *http.Request) { s.serveListing(w, r, r.URL.Query().Get("path")) } func (s *fileServer) serveListing(w http.ResponseWriter, r *http.Request, rel string) { data, ok := s.buildListing(w, r, rel) if !ok { return } s.renderListing(w, data) } func (s *fileServer) buildListing(w http.ResponseWriter, r *http.Request, rel string) (pageData, bool) { full, err := s.resolve(rel) if err != nil { s.writePathError(w, err) return pageData{}, false } info, err := os.Stat(full) if err != nil { s.writePathError(w, err) return pageData{}, false } rel = s.relOf(full) if !info.IsDir() { http.Redirect(w, r, downloadURL(rel), http.StatusFound) return pageData{}, false } des, err := os.ReadDir(full) if err != nil { log.Printf("读取目录失败 %s: %v", full, err) http.Error(w, "无法读取目录", http.StatusInternalServerError) return pageData{}, false } o := parseSortOrder(r.URL.Query()) adminEnabled := s.adminToken != "" loggedIn := false csrf := "" if adminEnabled { if payload, ok := s.sessionState(r); ok { loggedIn = true csrf = s.csrfFor(payload) } } entries := make([]entry, 0, len(des)) dirCount, fileCount := 0, 0 for _, de := range des { childRel := de.Name() if rel != "" { childRel = rel + "/" + de.Name() } childFull, err := s.resolve(childRel) if err != nil { continue } ci, err := os.Stat(childFull) if err != nil { continue } modTime := ci.ModTime().Local().Format("2006-01-02 15:04") switch { case ci.IsDir(): e := entry{ Name: de.Name(), IsDir: true, ModTime: modTime, ModUnix: ci.ModTime().UnixNano(), BrowseURL: browseURL(childRel, o), } if adminEnabled { e.DeleteURL = deleteConfirmURL(rel, de.Name(), o) e.RenameURL = renameConfirmURL(rel, de.Name(), o) } entries = append(entries, e) dirCount++ case ci.Mode().IsRegular(): e := entry{ Name: de.Name(), Size: humanSize(ci.Size()), SizeBytes: ci.Size(), ModTime: modTime, ModUnix: ci.ModTime().UnixNano(), DownloadURL: downloadURL(childRel), } if _, ok := previewContentType(de.Name()); ok { e.PreviewURL = previewURL(childRel) } if adminEnabled { e.DeleteURL = deleteConfirmURL(rel, de.Name(), o) e.RenameURL = renameConfirmURL(rel, de.Name(), o) } entries = append(entries, e) fileCount++ } } sort.Slice(entries, func(i, j int) bool { return lessEntry(entries[i], entries[j], o) }) var crumbs []crumb if rel != "" { parts := strings.Split(rel, "/") acc := "" for i, part := range parts { if acc == "" { acc = part } else { acc += "/" + part } crumbs = append(crumbs, crumb{ Name: part, URL: browseURL(acc, o), Current: i == len(parts)-1, }) } } parentURL := "" if rel != "" { parent := path.Dir(rel) if parent == "." { parent = "" } parentURL = browseURL(parent, o) } title := filepath.Base(s.root) if rel != "" { title = path.Base(rel) } if title == "" || title == "." || title == string(filepath.Separator) { title = s.root } errCode := r.URL.Query().Get("err") notice, noticeErr := pageNotice(r.URL.Query()) confirmName, confirmPath := "", "" deleteIsDir := false confirmRename := "" if adminEnabled { if name := r.URL.Query().Get("delete"); name != "" { if p, isDir, ok := s.confirmTarget(rel, name); ok { confirmName, confirmPath = name, p deleteIsDir = isDir } else { notice, noticeErr = "要删除的文件或文件夹不存在或无法删除。", true } } if name := r.URL.Query().Get("rename"); name != "" { if _, ok := s.entryInfo(rel, name); ok { confirmRename = name } else { notice, noticeErr = "要重命名的文件或文件夹不存在。", true } } } selectAll := r.URL.Query().Get("select") == "all" && len(entries) > 0 if selectAll { for i := range entries { entries[i].Selected = true } } data := pageData{ Title: title, Root: s.root, RootURL: browseURL("", o), Path: rel, Breadcrumbs: crumbs, ParentURL: parentURL, SelfURL: browseURL(rel, o), Entries: entries, DirCount: dirCount, FileCount: fileCount, AdminEnabled: adminEnabled, LoggedIn: loggedIn, CSRF: csrf, UploadOpen: adminEnabled && isUploadErr(errCode), MkdirOpen: adminEnabled && isMkdirErr(errCode), MaxUpload: humanSize(maxUploadBytes), Notice: notice, NoticeError: noticeErr, ConfirmName: confirmName, ConfirmPath: confirmPath, DeleteIsDir: deleteIsDir, ConfirmRename: confirmRename, SelectAll: selectAll, SelectAllURL: selectAllURL(rel, o), SortName: sortLinkFor(rel, "name", "名称", o), SortSize: sortLinkFor(rel, "size", "大小", o), SortTime: sortLinkFor(rel, "time", "修改时间", o), } return data, true } func (s *fileServer) renderListing(w http.ResponseWriter, data pageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") if err := s.tmpl.Execute(w, data); err != nil { log.Printf("渲染页面失败: %v", err) } } func (s *fileServer) handleDownload(w http.ResponseWriter, r *http.Request) { full, err := s.resolve(r.URL.Query().Get("path")) if err != nil { s.writePathError(w, err) return } info, err := os.Stat(full) if err != nil { s.writePathError(w, err) return } if info.IsDir() { http.Redirect(w, r, browseURL(s.relOf(full), sortOrder{key: "name"}), http.StatusFound) return } if !info.Mode().IsRegular() { http.Error(w, "不支持的文件类型", http.StatusBadRequest) return } f, err := os.Open(full) if err != nil { log.Printf("打开文件失败 %s: %v", full, err) http.Error(w, "无法打开文件", http.StatusInternalServerError) return } defer f.Close() disposition := mime.FormatMediaType("attachment", map[string]string{"filename": info.Name()}) if disposition == "" { disposition = "attachment" } w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeContent(w, r, info.Name(), info.ModTime(), f) } func (s *fileServer) handlePreview(w http.ResponseWriter, r *http.Request) { full, err := s.resolve(r.URL.Query().Get("path")) if err != nil { s.writePathError(w, err) return } info, err := os.Stat(full) if err != nil { s.writePathError(w, err) return } if info.IsDir() { http.Redirect(w, r, browseURL(s.relOf(full), sortOrder{key: "name"}), http.StatusFound) return } if !info.Mode().IsRegular() { http.Error(w, "不支持预览该文件类型", http.StatusBadRequest) return } contentType, ok := previewContentType(info.Name()) if !ok { http.Error(w, "该文件类型不支持在线预览", http.StatusUnsupportedMediaType) return } f, err := os.Open(full) if err != nil { log.Printf("打开预览文件失败 %s: %v", full, err) http.Error(w, "无法打开文件", http.StatusInternalServerError) return } defer f.Close() disposition := mime.FormatMediaType("inline", map[string]string{"filename": info.Name()}) if disposition == "" { disposition = "inline" } w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Type", contentType) w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeContent(w, r, info.Name(), info.ModTime(), f) } func (s *fileServer) handleUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "上传功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) if r.ContentLength > maxUploadBytes { actionFailure(w, r, apiMode, "", "size", "上传内容超过大小上限", http.StatusRequestEntityTooLarge) return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) err := r.ParseMultipartForm(32 << 20) if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() } if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { actionFailure(w, r, apiMode, "", "size", "上传内容超过大小上限", http.StatusRequestEntityTooLarge) return } actionFailure(w, r, apiMode, "", "form", "无法解析上传表单", http.StatusBadRequest) return } target, err := s.resolve(r.PostFormValue("path")) if err != nil { actionFailure(w, r, apiMode, "", "path", "目标目录无效或不可访问", http.StatusBadRequest) return } info, err := os.Stat(target) if err != nil || !info.IsDir() { actionFailure(w, r, apiMode, "", "path", "目标目录无效或不可访问", http.StatusBadRequest) return } relTarget := s.relOf(target) if !s.authorize(w, r, apiMode) { log.Printf("拒绝上传:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, relTarget, "token", "管理员令牌错误", http.StatusUnauthorized) return } conflict := r.PostFormValue("conflict") switch conflict { case "skip", "rename", "overwrite": default: conflict = "rename" } type uploadItem struct { header *multipart.FileHeader rel string } var items []uploadItem paths := r.MultipartForm.Value["paths"] for i, fh := range r.MultipartForm.File["files"] { if fh.Filename == "" { continue } raw := fh.Filename // Browsers cannot send a relative path as the multipart filename // because Go (and other servers) strip directory components. The UI // therefore sends a parallel "paths" field for directory uploads. if i < len(paths) && strings.TrimSpace(paths[i]) != "" { raw = paths[i] } rel, err := uploadRelPath(raw) if err != nil { actionFailure(w, r, apiMode, relTarget, "name", "文件名称无效", http.StatusBadRequest) return } items = append(items, uploadItem{header: fh, rel: rel}) } if len(items) == 0 { actionFailure(w, r, apiMode, relTarget, "empty", "请选择要上传的文件", http.StatusBadRequest) return } uploaded, skipped, renamed, failed := 0, 0, 0, 0 for _, it := range items { dest, skip, didRename, err := s.uploadDestination(target, it.rel, conflict) if err != nil { log.Printf("准备上传目标失败 %s/%s: %v", target, it.rel, err) failed++ continue } if skip { skipped++ continue } if err := saveUploadFile(it.header, dest, conflict == "overwrite"); err != nil { log.Printf("保存上传文件失败 %s: %v", dest, err) failed++ continue } uploaded++ if didRename { renamed++ } } log.Printf("上传完成: 成功 %d,跳过 %d,重命名 %d,失败 %d -> %s(来自 %s)", uploaded, skipped, renamed, failed, target, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "ok: uploaded %d, skipped %d, renamed %d, failed %d\n", uploaded, skipped, renamed, failed) return } q := url.Values{} q.Set("path", relTarget) q.Set("uploaded", strconv.Itoa(uploaded)) q.Set("skipped", strconv.Itoa(skipped)) q.Set("renamed", strconv.Itoa(renamed)) q.Set("failed", strconv.Itoa(failed)) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func (s *fileServer) handleMkdir(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "新建文件夹功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "mform", "无法解析表单", http.StatusBadRequest) return } rawPath := r.PostFormValue("path") if !s.authorize(w, r, apiMode) { log.Printf("拒绝新建文件夹:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, rawPath, "mtoken", "管理员令牌错误", http.StatusUnauthorized) return } target, err := s.resolve(rawPath) if err != nil { actionFailure(w, r, apiMode, "", "mpath", "目标目录无效或不可访问", http.StatusBadRequest) return } info, err := os.Stat(target) if err != nil || !info.IsDir() { actionFailure(w, r, apiMode, "", "mpath", "目标目录无效或不可访问", http.StatusBadRequest) return } relTarget := s.relOf(target) name, err := folderName(r.PostFormValue("name")) if err != nil { actionFailure(w, r, apiMode, relTarget, "mname", "文件夹名称无效", http.StatusBadRequest) return } dest := filepath.Join(target, name) if err := os.Mkdir(dest, 0o755); err != nil { if errors.Is(err, os.ErrExist) { actionFailure(w, r, apiMode, relTarget, "mexists", "同名文件或文件夹已存在", http.StatusConflict) return } log.Printf("创建文件夹失败 %s: %v", dest, err) actionFailure(w, r, apiMode, relTarget, "mwrite", "创建文件夹失败", http.StatusInternalServerError) return } log.Printf("已创建文件夹: %s(来自 %s)", dest, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok: folder created") return } q := url.Values{} q.Set("path", relTarget) q.Set("created", name) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func (s *fileServer) handleRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "重命名功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "rform", "无法解析表单", http.StatusBadRequest) return } rawPath := r.PostFormValue("path") if !s.authorize(w, r, apiMode) { log.Printf("拒绝重命名:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, rawPath, "rtoken", "管理员令牌错误", http.StatusUnauthorized) return } dir, err := s.resolve(rawPath) if err != nil { actionFailure(w, r, apiMode, "", "rpath", "目标目录无效或不可访问", http.StatusBadRequest) return } info, err := os.Stat(dir) if err != nil || !info.IsDir() { actionFailure(w, r, apiMode, "", "rpath", "目标目录无效或不可访问", http.StatusBadRequest) return } dirRel := s.relOf(dir) from, err := entryName(r.PostFormValue("from")) if err != nil { actionFailure(w, r, apiMode, dirRel, "rfrom", "原名称无效", http.StatusBadRequest) return } newName, err := folderName(r.PostFormValue("name")) if err != nil { actionFailure(w, r, apiMode, dirRel, "rname", "新名称无效", http.StatusBadRequest) return } if from == newName { actionFailure(w, r, apiMode, dirRel, "rsame", "新名称与原名称相同", http.StatusBadRequest) return } relFrom := from if dirRel != "" { relFrom = dirRel + "/" + from } full, err := s.resolveLeaf(relFrom) if err != nil { code, status := "rmissing", http.StatusNotFound if !errors.Is(err, os.ErrNotExist) { code, status = "rpath", http.StatusBadRequest } actionFailure(w, r, apiMode, dirRel, code, "要重命名的文件或文件夹不存在", status) return } srcInfo, err := os.Lstat(full) if err != nil { if errors.Is(err, os.ErrNotExist) { actionFailure(w, r, apiMode, dirRel, "rmissing", "要重命名的文件或文件夹不存在", http.StatusNotFound) } else { log.Printf("检查重命名源失败 %s: %v", full, err) actionFailure(w, r, apiMode, dirRel, "rfail", "重命名失败", http.StatusInternalServerError) } return } if srcInfo.Mode()&os.ModeSymlink == 0 && !srcInfo.IsDir() && !srcInfo.Mode().IsRegular() { actionFailure(w, r, apiMode, dirRel, "rtype", "不支持重命名该类型的文件", http.StatusBadRequest) return } dest := filepath.Join(dir, newName) if _, err := os.Lstat(dest); err == nil { actionFailure(w, r, apiMode, dirRel, "rexists", "同名文件或文件夹已存在", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { log.Printf("检查重命名目标失败 %s: %v", dest, err) actionFailure(w, r, apiMode, dirRel, "rfail", "重命名失败", http.StatusInternalServerError) return } if err := os.Rename(full, dest); err != nil { log.Printf("重命名失败 %s -> %s: %v", full, dest, err) actionFailure(w, r, apiMode, dirRel, "rfail", "重命名失败", http.StatusInternalServerError) return } log.Printf("已重命名: %s -> %s(来自 %s)", full, dest, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok: renamed") return } q := url.Values{} q.Set("path", dirRel) q.Set("renamed", newName) q.Set("renamed_from", from) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func (s *fileServer) handleDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "删除功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "dform", "无法解析表单", http.StatusBadRequest) return } rawPath := r.PostFormValue("path") if !s.authorize(w, r, apiMode) { log.Printf("拒绝删除:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, parentRel(rawPath), "dtoken", "管理员令牌错误", http.StatusUnauthorized) return } full, err := s.resolveLeaf(rawPath) if err != nil { code, status := "dpath", http.StatusBadRequest if errors.Is(err, os.ErrNotExist) { code, status = "dmissing", http.StatusNotFound } actionFailure(w, r, apiMode, "", code, "目标文件无效或不可访问", status) return } rel := s.relOf(full) info, err := os.Lstat(full) if err != nil { if errors.Is(err, os.ErrNotExist) { actionFailure(w, r, apiMode, parentRel(rel), "dmissing", "文件不存在", http.StatusNotFound) } else { log.Printf("检查文件失败 %s: %v", full, err) actionFailure(w, r, apiMode, parentRel(rel), "dfail", "删除文件失败", http.StatusInternalServerError) } return } if info.IsDir() { if err := os.RemoveAll(full); err != nil { log.Printf("删除文件夹失败 %s: %v", full, err) actionFailure(w, r, apiMode, parentRel(rel), "dfail", "删除文件夹失败", http.StatusInternalServerError) return } log.Printf("已删除文件夹: %s(来自 %s)", full, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok: folder deleted") return } q := url.Values{} q.Set("path", parentRel(rel)) q.Set("deleted", filepath.Base(full)) q.Set("deleted_kind", "dir") http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) return } if info.Mode()&os.ModeSymlink == 0 && !info.Mode().IsRegular() { actionFailure(w, r, apiMode, parentRel(rel), "dtype", "只能删除文件和文件夹", http.StatusBadRequest) return } if err := os.Remove(full); err != nil { log.Printf("删除文件失败 %s: %v", full, err) actionFailure(w, r, apiMode, parentRel(rel), "dfail", "删除文件失败", http.StatusInternalServerError) return } log.Printf("已删除文件: %s(来自 %s)", full, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok: file deleted") return } q := url.Values{} q.Set("path", parentRel(rel)) q.Set("deleted", filepath.Base(full)) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func (s *fileServer) tokenOK(got string) bool { if s.adminToken == "" || got == "" { return false } return subtle.ConstantTimeCompare([]byte(got), []byte(s.adminToken)) == 1 } const ( sessionCookieName = "myfile_session" sessionTTL = 12 * time.Hour ) func apiRequest(r *http.Request) bool { return r.Header.Get("X-Admin-Token") != "" || r.Header.Get("X-Requested-With") == "XMLHttpRequest" } func (s *fileServer) mac(payload string) string { m := hmac.New(sha256.New, s.secret) m.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } func (s *fileServer) parseSession(value string) (string, bool) { payloadB64, sigB64, ok := strings.Cut(value, ".") if !ok || payloadB64 == "" || sigB64 == "" { return "", false } payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) if err != nil { return "", false } payload := string(payloadBytes) if subtle.ConstantTimeCompare([]byte(sigB64), []byte(s.mac(payload))) != 1 { return "", false } parts := strings.Split(payload, "|") if len(parts) != 3 || parts[0] != "v1" { return "", false } exp, err := strconv.ParseInt(parts[2], 10, 64) if err != nil || time.Now().Unix() >= exp { return "", false } return payload, true } func (s *fileServer) sessionState(r *http.Request) (string, bool) { c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { return "", false } return s.parseSession(c.Value) } func (s *fileServer) csrfFor(payload string) string { return s.mac("csrf|" + payload) } func (s *fileServer) setSession(w http.ResponseWriter, r *http.Request) { raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { log.Printf("生成会话失败: %v", err) return } payload := fmt.Sprintf("v1|%s|%d", base64.RawURLEncoding.EncodeToString(raw), time.Now().Add(sessionTTL).Unix()) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + s.mac(payload), Path: "/", MaxAge: int(sessionTTL / time.Second), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, }) } func (s *fileServer) clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } // authorize accepts an admin token header (API clients), a raw token form // value submitted once, or an existing session cookie paired with its CSRF // token. A successful raw token submission also starts a browser session. func (s *fileServer) authorize(w http.ResponseWriter, r *http.Request, apiMode bool) bool { if s.tokenOK(r.Header.Get("X-Admin-Token")) { return true } if s.tokenOK(r.PostFormValue("token")) { s.setSession(w, r) return true } if payload, ok := s.sessionState(r); ok { csrf := r.PostFormValue("csrf") if csrf != "" && subtle.ConstantTimeCompare([]byte(csrf), []byte(s.csrfFor(payload))) == 1 { return true } } return false } func (s *fileServer) handleLogout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "lform", "无法解析表单", http.StatusBadRequest) return } if s.adminToken != "" && !s.authorize(w, r, apiMode) { actionFailure(w, r, apiMode, "", "ltoken", "未登录或登录已过期", http.StatusUnauthorized) return } s.clearSession(w) log.Printf("已退出登录(来自 %s)", r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok: logged out") return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *fileServer) handleBatch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, maxBatchFormBytes) if err := r.ParseForm(); err != nil { http.Error(w, "无法解析表单", http.StatusBadRequest) return } target, err := s.resolve(r.PostFormValue("path")) if err != nil { actionFailure(w, r, false, "", "path", "目标目录无效或不可访问", http.StatusBadRequest) return } info, err := os.Stat(target) if err != nil || !info.IsDir() { actionFailure(w, r, false, "", "path", "目标目录无效或不可访问", http.StatusBadRequest) return } dirRel := s.relOf(target) names, err := selectedNames(r.PostForm["file"]) if err != nil { actionFailure(w, r, false, dirRel, "bname", "所选文件名无效", http.StatusBadRequest) return } if len(names) == 0 { actionFailure(w, r, false, dirRel, "noselect", "请选择至少一个文件或文件夹", http.StatusBadRequest) return } switch r.PostFormValue("action") { case "download": s.serveZip(w, r, dirRel, names) case "delete": if s.adminToken == "" { http.Error(w, "删除功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } data, ok := s.buildListing(w, r, dirRel) if !ok { return } selected := make(map[string]bool, len(names)) dirs := make(map[string]bool, len(data.Entries)) for _, name := range names { selected[name] = true } for i := range data.Entries { if selected[data.Entries[i].Name] { data.Entries[i].Selected = true } dirs[data.Entries[i].Name] = data.Entries[i].IsDir } items := make([]batchItem, 0, len(names)) for _, name := range names { it := batchItem{Name: name, IsDir: dirs[name]} if it.IsDir { data.ConfirmHasDir = true } items = append(items, it) } data.ConfirmMany = items data.ConfirmDir = dirRel s.renderListing(w, data) default: actionFailure(w, r, false, dirRel, "baction", "不支持的操作", http.StatusBadRequest) } } func (s *fileServer) serveZip(w http.ResponseWriter, r *http.Request, dirRel string, names []string) { type zipItem struct { name string full string mod time.Time isDir bool } items := make([]zipItem, 0, len(names)) fileCount, dirCount := 0, 0 for _, name := range names { rel := name if dirRel != "" { rel = dirRel + "/" + name } full, err := s.resolve(rel) if err != nil { continue } info, err := os.Stat(full) if err != nil { continue } if !info.IsDir() { if info.Mode().IsRegular() { items = append(items, zipItem{name: name, full: full, mod: info.ModTime()}) fileCount++ } continue } err = filepath.WalkDir(full, func(p string, d fs.DirEntry, walkErr error) error { if walkErr != nil { log.Printf("打包遍历失败 %s: %v", p, walkErr) if d != nil && d.IsDir() { return fs.SkipDir } return nil } if d.Type()&fs.ModeSymlink != 0 { return nil } sub, err := filepath.Rel(full, p) if err != nil { return nil } zipPath := name if sub != "." { zipPath = path.Join(name, filepath.ToSlash(sub)) } if d.IsDir() { fi, err := d.Info() if err != nil { return nil } items = append(items, zipItem{name: zipPath + "/", mod: fi.ModTime(), isDir: true}) dirCount++ return nil } if !d.Type().IsRegular() { return nil } fi, err := d.Info() if err != nil { return nil } items = append(items, zipItem{name: zipPath, full: p, mod: fi.ModTime()}) fileCount++ return nil }) if err != nil { log.Printf("打包遍历失败 %s: %v", full, err) } } if fileCount == 0 && dirCount == 0 { actionFailure(w, r, false, dirRel, "zipnone", "所选项目均不可下载", http.StatusBadRequest) return } base := filepath.Base(s.root) if dirRel != "" { base = path.Base(dirRel) } zipName := "files.zip" if base != "" && base != "." && base != string(filepath.Separator) { zipName = base + ".zip" } disposition := mime.FormatMediaType("attachment", map[string]string{"filename": zipName}) if disposition == "" { disposition = "attachment" } w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Type", "application/zip") w.Header().Set("X-Content-Type-Options", "nosniff") zw := zip.NewWriter(w) for _, it := range items { hdr := &zip.FileHeader{Name: it.name, Method: zip.Deflate, Modified: it.mod} if it.isDir { hdr.Method = zip.Store if _, err := zw.CreateHeader(hdr); err != nil { log.Printf("打包写入失败: %v", err) return } continue } f, err := os.Open(it.full) if err != nil { log.Printf("打包打开文件失败 %s: %v", it.full, err) continue } dst, err := zw.CreateHeader(hdr) if err != nil { f.Close() log.Printf("打包写入失败: %v", err) return } if _, err := io.Copy(dst, f); err != nil { f.Close() log.Printf("打包复制文件失败 %s: %v", it.full, err) return } f.Close() } if err := zw.Close(); err != nil { log.Printf("打包关闭失败: %v", err) return } log.Printf("已打包 %d 个文件、%d 个文件夹供下载(来自 %s)", fileCount, dirCount, r.RemoteAddr) } func (s *fileServer) handleBatchDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "删除功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxBatchFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "dform", "无法解析表单", http.StatusBadRequest) return } rawPath := r.PostFormValue("path") if !s.authorize(w, r, apiMode) { log.Printf("拒绝批量删除:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, rawPath, "dtoken", "管理员令牌错误", http.StatusUnauthorized) return } target, err := s.resolve(rawPath) if err != nil { actionFailure(w, r, apiMode, "", "dpath", "目标目录无效或不可访问", http.StatusBadRequest) return } info, err := os.Stat(target) if err != nil || !info.IsDir() { actionFailure(w, r, apiMode, "", "dpath", "目标目录无效或不可访问", http.StatusBadRequest) return } dirRel := s.relOf(target) names, err := selectedNames(r.PostForm["file"]) if err != nil { actionFailure(w, r, apiMode, dirRel, "bname", "所选文件名无效", http.StatusBadRequest) return } if len(names) == 0 { actionFailure(w, r, apiMode, dirRel, "noselect", "请选择至少一个文件或文件夹", http.StatusBadRequest) return } deleted, failed := 0, 0 for _, name := range names { rel := name if dirRel != "" { rel = dirRel + "/" + name } full, err := s.resolveLeaf(rel) if err != nil { failed++ continue } fi, err := os.Lstat(full) if err != nil || (!fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 && !fi.Mode().IsRegular()) { failed++ continue } if fi.IsDir() { if err := os.RemoveAll(full); err != nil { log.Printf("批量删除文件夹失败 %s: %v", full, err) failed++ continue } deleted++ log.Printf("已删除文件夹: %s(来自 %s)", full, r.RemoteAddr) continue } if err := os.Remove(full); err != nil { log.Printf("批量删除失败 %s: %v", full, err) failed++ continue } deleted++ log.Printf("已删除文件: %s(来自 %s)", full, r.RemoteAddr) } if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "ok: deleted %d item(s), failed %d\n", deleted, failed) return } q := url.Values{} q.Set("path", dirRel) q.Set("deleted_many", strconv.Itoa(deleted)) if failed > 0 { q.Set("failed_many", strconv.Itoa(failed)) } http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } const ( searchMaxDepth = 6 searchMaxResults = 300 searchMaxScanned = 50000 ) func (s *fileServer) handleSearch(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) rel := r.URL.Query().Get("path") if query == "" { s.serveListing(w, r, rel) return } data, ok := s.buildListing(w, r, rel) if !ok { return } o := parseSortOrder(r.URL.Query()) base := data.Path results, scanned, truncated := s.searchTree(base, query, o) data.IsSearch = true data.SearchQuery = query data.SearchBase = base data.SearchBaseURL = browseURL(base, o) data.SearchResults = results data.SearchScanned = scanned data.SearchTrunc = truncated data.Title = "搜索:" + query data.ConfirmName = "" data.ConfirmPath = "" data.ConfirmRename = "" data.ConfirmMany = nil data.Notice = "" data.NoticeError = false s.renderListing(w, data) } func (s *fileServer) searchTree(base, query string, o sortOrder) ([]entry, int, bool) { root, err := s.resolve(base) if err != nil { return nil, 0, false } needle := strings.ToLower(query) var results []entry scanned, truncated := 0, false _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { if walkErr != nil { if d != nil && d.IsDir() { return fs.SkipDir } return nil } if p == root { return nil } if d.Type()&fs.ModeSymlink != 0 { if d.IsDir() { return fs.SkipDir } return nil } sub, err := filepath.Rel(root, p) if err != nil { return nil } subSlash := filepath.ToSlash(sub) depth := strings.Count(subSlash, "/") + 1 scanned++ if scanned > searchMaxScanned { truncated = true return fs.SkipAll } if d.IsDir() && depth >= searchMaxDepth { if strings.Contains(strings.ToLower(d.Name()), needle) { if len(results) >= searchMaxResults { truncated = true return fs.SkipAll } collectSearchEntry(&results, s, p, subSlash, d, o) } return fs.SkipDir } if strings.Contains(strings.ToLower(d.Name()), needle) { if len(results) >= searchMaxResults { truncated = true return fs.SkipAll } collectSearchEntry(&results, s, p, subSlash, d, o) } return nil }) if len(results) > searchMaxResults { results = results[:searchMaxResults] truncated = true } sort.Slice(results, func(i, j int) bool { return lessName(results[i].Rel, results[j].Rel, false) }) return results, scanned, truncated } func collectSearchEntry(results *[]entry, s *fileServer, full, sub string, d fs.DirEntry, o sortOrder) { info, err := d.Info() if err != nil { return } rel := s.relOf(full) e := entry{ Name: d.Name(), Rel: sub, ModTime: info.ModTime().Local().Format("2006-01-02 15:04"), ModUnix: info.ModTime().UnixNano(), } if d.IsDir() { e.IsDir = true e.BrowseURL = browseURL(rel, o) } else if info.Mode().IsRegular() { e.Size = humanSize(info.Size()) e.SizeBytes = info.Size() e.DownloadURL = downloadURL(rel) if _, ok := previewContentType(d.Name()); ok { e.PreviewURL = previewURL(rel) } parentRel := path.Dir(rel) if parentRel == "." { parentRel = "" } e.BrowseURL = browseURL(parentRel, o) } else { return } *results = append(*results, e) } func (s *fileServer) handleTransfer(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) return } if s.adminToken == "" { http.Error(w, "移动/复制功能未启用:服务器未设置 admin_token 环境变量", http.StatusForbidden) return } apiMode := apiRequest(r) r.Body = http.MaxBytesReader(w, r.Body, maxBatchFormBytes) if err := r.ParseForm(); err != nil { actionFailure(w, r, apiMode, "", "tform", "无法解析表单", http.StatusBadRequest) return } rawDst := r.PostFormValue("path") if !s.authorize(w, r, apiMode) { log.Printf("拒绝移动/复制:管理员令牌或会话无效(%s)", r.RemoteAddr) actionFailure(w, r, apiMode, rawDst, "ttoken", "管理员令牌错误", http.StatusUnauthorized) return } mode := r.PostFormValue("mode") if mode != "move" && mode != "copy" { actionFailure(w, r, apiMode, rawDst, "tmode", "不支持的操作", http.StatusBadRequest) return } dst, err := s.resolve(rawDst) if err != nil { actionFailure(w, r, apiMode, "", "tpath", "目标目录无效或不可访问", http.StatusBadRequest) return } di, err := os.Stat(dst) if err != nil || !di.IsDir() { actionFailure(w, r, apiMode, "", "tpath", "目标目录无效或不可访问", http.StatusBadRequest) return } dstRel := s.relOf(dst) src, err := s.resolve(r.PostFormValue("src")) if err != nil { actionFailure(w, r, apiMode, dstRel, "tsrc", "源目录无效或不可访问", http.StatusBadRequest) return } si, err := os.Stat(src) if err != nil || !si.IsDir() { actionFailure(w, r, apiMode, dstRel, "tsrc", "源目录无效或不可访问", http.StatusBadRequest) return } srcRel := s.relOf(src) names, err := selectedNames(r.PostForm["file"]) if err != nil { actionFailure(w, r, apiMode, dstRel, "bname", "所选文件名无效", http.StatusBadRequest) return } if len(names) == 0 { actionFailure(w, r, apiMode, dstRel, "noselect", "请选择至少一个文件或文件夹", http.StatusBadRequest) return } moved, copied, renamed, failed := 0, 0, 0, 0 for _, name := range names { srcLeaf := srcRelRel(srcRel, name) srcFull, err := s.resolveLeaf(srcLeaf) if err != nil { failed++ continue } fi, err := os.Lstat(srcFull) if err != nil { failed++ continue } if fi.IsDir() && withinRoot(srcFull, dst) { log.Printf("拒绝移动/复制文件夹到自身内部: %s -> %s", srcFull, dst) failed++ continue } dest := filepath.Join(dst, name) didRename := false if _, statErr := os.Lstat(dest); statErr == nil { dest = uniqueDest(dst, name) didRename = true } else if !errors.Is(statErr, os.ErrNotExist) { log.Printf("检查目标位置失败 %s: %v", dest, statErr) failed++ continue } if mode == "move" { moveErr := os.Rename(srcFull, dest) if moveErr != nil { // Cross-device moves fall back to copy + delete. if err := copyTree(srcFull, dest); err != nil { os.RemoveAll(dest) log.Printf("移动失败 %s -> %s: %v(复制回退也失败: %v)", srcFull, dest, moveErr, err) failed++ continue } if err := os.RemoveAll(srcFull); err != nil { log.Printf("移动后删除源失败 %s: %v", srcFull, err) failed++ continue } } moved++ } else { if err := copyTree(srcFull, dest); err != nil { os.RemoveAll(dest) log.Printf("复制失败 %s -> %s: %v", srcFull, dest, err) failed++ continue } copied++ } if didRename { renamed++ } } label := "移动" if mode == "copy" { label = "复制" } log.Printf("%s完成: 成功 %d,重命名 %d,失败 %d(%s -> %s,来自 %s)", label, moved+copied, renamed, failed, srcRel, dstRel, r.RemoteAddr) if apiMode { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "ok: moved %d, copied %d, renamed %d, failed %d\n", moved, copied, renamed, failed) return } q := url.Values{} q.Set("path", dstRel) q.Set("transfer", mode) q.Set("t_moved", strconv.Itoa(moved)) q.Set("t_copied", strconv.Itoa(copied)) q.Set("t_renamed", strconv.Itoa(renamed)) q.Set("t_failed", strconv.Itoa(failed)) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func srcRelRel(dirRel, name string) string { if dirRel == "" { return name } return dirRel + "/" + name } func copyTree(src, dst string) error { info, err := os.Lstat(src) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return errors.New("不复制符号链接") } if info.IsDir() { if err := os.Mkdir(dst, info.Mode().Perm()|0o700); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, de := range entries { if de.Type()&fs.ModeSymlink != 0 { continue } if err := copyTree(filepath.Join(src, de.Name()), filepath.Join(dst, de.Name())); err != nil { return err } } return nil } if !info.Mode().IsRegular() { return nil } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(dst) return err } if err := out.Close(); err != nil { os.Remove(dst) return err } return nil } func selectedNames(raw []string) ([]string, error) { seen := make(map[string]bool, len(raw)) names := make([]string, 0, len(raw)) for _, v := range raw { name, err := entryName(v) if err != nil { return nil, errInvalidName } if seen[name] { continue } seen[name] = true names = append(names, name) } return names, nil } // uploadRelPath cleans a browser-provided upload file name. Browsers send // directory uploads as a slash- or backslash-separated relative path, which is // preserved so the directory structure is recreated on the server. func uploadRelPath(raw string) (string, error) { if raw == "" || strings.ContainsRune(raw, 0) { return "", errInvalidName } cleaned := strings.ReplaceAll(raw, "\\", "/") segs := strings.Split(cleaned, "/") out := make([]string, 0, len(segs)) for _, seg := range segs { seg = strings.TrimSpace(seg) if seg == "" || seg == "." { continue } if seg == ".." || strings.HasSuffix(seg, ".") { return "", errInvalidName } if strings.ContainsAny(seg, `:*?"<>|`) { return "", errInvalidName } for _, r := range seg { if r < 0x20 || r == 0x7f { return "", errInvalidName } } out = append(out, seg) } if len(out) == 0 { return "", errInvalidName } return strings.Join(out, "/"), nil } // uploadDestination creates any missing parent directories and resolves the // final path according to the requested conflict policy. func (s *fileServer) uploadDestination(target, rel, conflict string) (dest string, skip, renamed bool, err error) { segs := strings.Split(rel, "/") dir := target for _, seg := range segs[:len(segs)-1] { dir = filepath.Join(dir, seg) info, statErr := os.Lstat(dir) if statErr == nil { if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return "", false, false, errInvalidName } continue } if !errors.Is(statErr, os.ErrNotExist) { return "", false, false, statErr } if mkErr := os.Mkdir(dir, 0o755); mkErr != nil && !errors.Is(mkErr, os.ErrExist) { return "", false, false, mkErr } } parent, err := filepath.EvalSymlinks(dir) if err != nil { return "", false, false, err } if !withinRoot(s.root, parent) { return "", false, false, errBadPath } name := segs[len(segs)-1] dest = filepath.Join(parent, name) info, err := os.Lstat(dest) if err != nil { if errors.Is(err, os.ErrNotExist) { return dest, false, false, nil } return "", false, false, err } switch conflict { case "skip": return "", true, false, nil case "overwrite": if !info.IsDir() { return dest, false, false, nil } } return uniqueDest(parent, name), false, true, nil } // uniqueDest appends " (n)" until the name is free. func uniqueDest(dir, name string) string { ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) for i := 1; i < 1000; i++ { candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext)) if _, err := os.Lstat(candidate); errors.Is(err, os.ErrNotExist) { return candidate } } return filepath.Join(dir, name) } func folderName(raw string) (string, error) { name := strings.TrimSpace(raw) if name == "" || name == "." || name == ".." { return "", errInvalidName } if strings.ContainsAny(name, `/\`) || strings.HasSuffix(name, ".") { return "", errInvalidName } for _, r := range name { if r < 0x20 || r == 0x7f { return "", errInvalidName } } return name, nil } func entryName(raw string) (string, error) { if raw == "" || raw == "." || raw == ".." || strings.ContainsRune(raw, 0) { return "", errInvalidName } if strings.ContainsAny(raw, `/\`) { return "", errInvalidName } return raw, nil } func parentRel(rel string) string { parent := path.Dir(rel) if parent == "." || parent == "/" { return "" } return parent } func deleteConfirmURL(dirRel, name string, o sortOrder) string { q := url.Values{} q.Set("path", dirRel) q.Set("delete", name) o.addValues(q) return "/browse?" + q.Encode() } func renameConfirmURL(dirRel, name string, o sortOrder) string { q := url.Values{} q.Set("path", dirRel) q.Set("rename", name) o.addValues(q) return "/browse?" + q.Encode() } func saveUploadFile(fh *multipart.FileHeader, dest string, overwrite bool) error { src, err := fh.Open() if err != nil { return err } defer src.Close() if overwrite { if info, err := os.Lstat(dest); err == nil && info.Mode()&os.ModeSymlink != 0 { if err := os.Remove(dest); err != nil { return err } } } flags := os.O_WRONLY | os.O_CREATE if overwrite { flags |= os.O_TRUNC } else { flags |= os.O_EXCL } dst, err := os.OpenFile(dest, flags, 0o644) if err != nil { return err } if _, err := io.Copy(dst, src); err != nil { dst.Close() os.Remove(dest) return err } if err := dst.Close(); err != nil { os.Remove(dest) return err } return nil } func actionFailure(w http.ResponseWriter, r *http.Request, apiMode bool, backRel, code, message string, status int) { if apiMode { http.Error(w, message, status) return } q := url.Values{} q.Set("path", backRel) q.Set("err", code) http.Redirect(w, r, "/browse?"+q.Encode(), http.StatusSeeOther) } func pageNotice(q url.Values) (string, bool) { switch q.Get("err") { case "token": return "管理员令牌错误,未上传任何文件。", true case "path": return "目标目录无效或不可访问。", true case "empty": return "请选择至少一个文件。", true case "name": return "文件名称无效,未上传任何文件。", true case "exists": return "目标目录中已存在同名文件,未上传任何文件。", true case "form": return "上传表单无效,请重试。", true case "size": return fmt.Sprintf("上传内容超过 %s 上限。", humanSize(maxUploadBytes)), true case "write": return "写入文件失败,本次上传已回滚。", true case "mtoken": return "管理员令牌错误,未创建文件夹。", true case "mpath": return "目标目录无效或不可访问。", true case "mform": return "新建文件夹表单无效,请重试。", true case "mname": return "文件夹名称无效:不能为空,不能包含斜杠或特殊字符。", true case "mexists": return "同名文件或文件夹已存在。", true case "mwrite": return "创建文件夹失败。", true case "dtoken": return "管理员令牌错误,未删除任何内容。", true case "dpath": return "目标文件无效或不可访问。", true case "dform": return "删除表单无效,请重试。", true case "dmissing": return "要删除的文件或文件夹不存在或已被删除。", true case "dtype": return "只能删除文件和文件夹。", true case "dfail": return "删除失败。", true case "noselect": return "请选择至少一个文件或文件夹。", true case "zipnone": return "所选项目均不可下载。", true case "bname": return "所选名称无效。", true case "baction": return "不支持的操作。", true case "rtoken": return "管理员令牌错误,未重命名。", true case "rpath": return "目标目录无效或不可访问。", true case "rform": return "重命名表单无效,请重试。", true case "rfrom": return "原名称无效。", true case "rname": return "新名称无效:不能为空,不能包含斜杠或特殊字符。", true case "rsame": return "新名称与原名称相同。", true case "rmissing": return "要重命名的文件或文件夹不存在。", true case "rtype": return "不支持重命名该类型的文件。", true case "rexists": return "同名文件或文件夹已存在。", true case "rfail": return "重命名失败。", true case "ltoken": return "未登录或登录已过期,请刷新页面后重试。", true case "lform": return "退出登录表单无效,请重试。", true case "ttoken": return "管理员令牌错误,未执行移动/复制。", true case "tmode": return "不支持的移动/复制操作。", true case "tpath": return "目标目录无效或不可访问。", true case "tsrc": return "源目录无效或不可访问。", true case "tform": return "移动/复制表单无效,请重试。", true } if n := q.Get("deleted_many"); n != "" { deleted, _ := strconv.Atoi(n) failed, _ := strconv.Atoi(q.Get("failed_many")) switch { case deleted > 0 && failed > 0: return fmt.Sprintf("已删除 %d 个项目,%d 个未能删除。", deleted, failed), true case deleted > 0: return fmt.Sprintf("已删除 %d 个项目。", deleted), false case failed > 0: return "未能删除所选项目,请重试。", true } } if mode := q.Get("transfer"); mode != "" { moved, _ := strconv.Atoi(q.Get("t_moved")) copied, _ := strconv.Atoi(q.Get("t_copied")) renamed, _ := strconv.Atoi(q.Get("t_renamed")) failed, _ := strconv.Atoi(q.Get("t_failed")) verb := "移动" if mode == "copy" { verb = "复制" } total := moved + copied switch { case total > 0 && failed > 0: return fmt.Sprintf("已%s %d 个项目,%d 个未能%s。", verb, total, failed, verb), true case total > 0 && renamed > 0: return fmt.Sprintf("已%s %d 个项目(%d 个因同名自动重命名)。", verb, total, renamed), false case total > 0: return fmt.Sprintf("已%s %d 个项目。", verb, total), false case failed > 0: return fmt.Sprintf("未能%s所选项目,请检查源目录和目标目录。", verb), true } } if to := q.Get("renamed"); to != "" && q.Get("renamed_from") != "" { return fmt.Sprintf("已将「%s」重命名为「%s」。", q.Get("renamed_from"), to), false } if name := q.Get("created"); name != "" { return fmt.Sprintf("已创建文件夹「%s」。", name), false } if name := q.Get("deleted"); name != "" { if q.Get("deleted_kind") == "dir" { return fmt.Sprintf("已删除文件夹「%s」及其内容。", name), false } return fmt.Sprintf("已删除文件「%s」。", name), false } if q.Get("uploaded") != "" || q.Get("skipped") != "" || q.Get("failed") != "" { uploaded, _ := strconv.Atoi(q.Get("uploaded")) skipped, _ := strconv.Atoi(q.Get("skipped")) renamed, _ := strconv.Atoi(q.Get("renamed")) failed, _ := strconv.Atoi(q.Get("failed")) var parts []string if uploaded > 0 { parts = append(parts, fmt.Sprintf("已成功上传 %d 个文件", uploaded)) } if renamed > 0 { parts = append(parts, fmt.Sprintf("%d 个因同名自动重命名", renamed)) } if skipped > 0 { parts = append(parts, fmt.Sprintf("跳过 %d 个同名文件", skipped)) } if failed > 0 { parts = append(parts, fmt.Sprintf("%d 个上传失败", failed)) } if len(parts) > 0 { return strings.Join(parts, ",") + "。", failed > 0 && uploaded == 0 } } return "", false } func isUploadErr(code string) bool { switch code { case "token", "path", "empty", "name", "exists", "form", "size", "write": return true } return false } func isMkdirErr(code string) bool { switch code { case "mtoken", "mpath", "mform", "mname", "mexists", "mwrite": return true } return false } func (s *fileServer) writePathError(w http.ResponseWriter, err error) { switch { case errors.Is(err, os.ErrNotExist): http.Error(w, "路径不存在", http.StatusNotFound) case errors.Is(err, errBadPath): http.Error(w, "非法路径", http.StatusBadRequest) default: log.Printf("路径解析失败: %v", err) http.Error(w, "无法访问该路径", http.StatusInternalServerError) } } // resolve maps a URL relative path to a real path, rejecting anything that // escapes the served root (including through symlinks). func (s *fileServer) resolve(rel string) (string, error) { if strings.ContainsRune(rel, 0) { return "", errBadPath } clean := filepath.Clean(filepath.FromSlash(strings.TrimLeft(rel, "/"))) if clean == "." { clean = "" } if clean != "" { if filepath.IsAbs(clean) || filepath.VolumeName(clean) != "" { return "", errBadPath } if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", errBadPath } } full, err := filepath.EvalSymlinks(filepath.Join(s.root, clean)) if err != nil { return "", err } full, err = filepath.Abs(full) if err != nil { return "", err } if !withinRoot(s.root, full) { return "", errBadPath } return full, nil } // resolveLeaf resolves everything except the final path element, so deleting a // symlink removes the link itself rather than its target. func (s *fileServer) resolveLeaf(rel string) (string, error) { if strings.ContainsRune(rel, 0) { return "", errBadPath } clean := filepath.Clean(filepath.FromSlash(strings.TrimLeft(rel, "/"))) if clean == "" || clean == "." { return "", errBadPath } if filepath.IsAbs(clean) || filepath.VolumeName(clean) != "" { return "", errBadPath } if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", errBadPath } full := filepath.Join(s.root, clean) if !withinRoot(s.root, full) { return "", errBadPath } parent, err := filepath.EvalSymlinks(filepath.Dir(full)) if err != nil { return "", err } if !withinRoot(s.root, parent) { return "", errBadPath } return full, nil } func (s *fileServer) confirmTarget(dirRel, name string) (string, bool, bool) { if _, err := entryName(name); err != nil { return "", false, false } rel := name if dirRel != "" { rel = dirRel + "/" + name } full, err := s.resolveLeaf(rel) if err != nil { return "", false, false } info, err := os.Lstat(full) if err != nil { return "", false, false } if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 && !info.Mode().IsRegular() { return "", false, false } return rel, info.IsDir(), true } func (s *fileServer) entryInfo(dirRel, name string) (os.FileInfo, bool) { if _, err := entryName(name); err != nil { return nil, false } rel := name if dirRel != "" { rel = dirRel + "/" + name } full, err := s.resolveLeaf(rel) if err != nil { return nil, false } info, err := os.Lstat(full) if err != nil { return nil, false } return info, true } func (s *fileServer) relOf(full string) string { rel, err := filepath.Rel(s.root, full) if err != nil || rel == "." { return "" } return filepath.ToSlash(rel) } func withinRoot(root, target string) bool { rel, err := filepath.Rel(root, target) if err != nil { return false } if rel == "." { return true } return !filepath.IsAbs(rel) && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } type sortOrder struct { key string desc bool } func parseSortOrder(q url.Values) sortOrder { o := sortOrder{key: "name"} switch q.Get("sort") { case "name", "size", "time": o.key = q.Get("sort") } if o.key != "name" { o.desc = true } switch q.Get("order") { case "asc": o.desc = false case "desc": o.desc = true } return o } func (o sortOrder) isDefault() bool { return o.key == "name" && !o.desc } func (o sortOrder) addValues(v url.Values) { if o.isDefault() { return } v.Set("sort", o.key) if o.desc { v.Set("order", "desc") } else { v.Set("order", "asc") } } func lessName(a, b string, desc bool) bool { al, bl := strings.ToLower(a), strings.ToLower(b) if al == bl { return a < b } if desc { return al > bl } return al < bl } func lessEntry(a, b entry, o sortOrder) bool { if a.IsDir != b.IsDir { return a.IsDir } switch o.key { case "size": if a.SizeBytes != b.SizeBytes { if o.desc { return a.SizeBytes > b.SizeBytes } return a.SizeBytes < b.SizeBytes } case "time": if a.ModUnix != b.ModUnix { if o.desc { return a.ModUnix > b.ModUnix } return a.ModUnix < b.ModUnix } default: return lessName(a.Name, b.Name, o.desc) } return lessName(a.Name, b.Name, false) } func sortLinkFor(rel, key, label string, cur sortOrder) sortLink { next := sortOrder{key: key, desc: key != "name"} active := cur.key == key if active { next.desc = !cur.desc } link := sortLink{URL: browseURL(rel, next), Active: active} if active { if cur.desc { link.Arrow = "↓" link.Label = label + ",当前降序,点击切换为升序" } else { link.Arrow = "↑" link.Label = label + ",当前升序,点击切换为降序" } return link } if next.desc { link.Label = label + ",点击按降序排列" } else { link.Label = label + ",点击按升序排列" } return link } func browseURL(rel string, o sortOrder) string { v := url.Values{} if rel != "" { v.Set("path", rel) } o.addValues(v) if len(v) == 0 { return "/" } if rel == "" { return "/?" + v.Encode() } return "/browse?" + v.Encode() } func selectAllURL(rel string, o sortOrder) string { v := url.Values{} if rel != "" { v.Set("path", rel) } v.Set("select", "all") o.addValues(v) return "/browse?" + v.Encode() } func downloadURL(rel string) string { return "/download?" + url.Values{"path": {rel}}.Encode() } func previewURL(rel string) string { return "/preview?" + url.Values{"path": {rel}}.Encode() } // previewContentType returns the safe inline content type for previewable // files. HTML and SVG are deliberately excluded to avoid script execution. func previewContentType(name string) (string, bool) { switch strings.ToLower(filepath.Ext(name)) { case ".jpg", ".jpeg": return "image/jpeg", true case ".png": return "image/png", true case ".gif": return "image/gif", true case ".webp": return "image/webp", true case ".bmp": return "image/bmp", true case ".avif": return "image/avif", true case ".ico": return "image/x-icon", true case ".mp4", ".m4v": return "video/mp4", true case ".webm": return "video/webm", true case ".ogv": return "video/ogg", true case ".mov": return "video/quicktime", true case ".mp3": return "audio/mpeg", true case ".wav": return "audio/wav", true case ".ogg", ".oga": return "audio/ogg", true case ".m4a": return "audio/mp4", true case ".flac": return "audio/flac", true case ".aac": return "audio/aac", true case ".pdf": return "application/pdf", true case ".txt", ".log", ".md", ".markdown", ".json", ".xml", ".csv", ".yml", ".yaml", ".ini", ".conf", ".go", ".js", ".ts", ".css", ".sh", ".bat", ".ps1", ".py", ".java", ".c", ".cpp", ".h", ".rs", ".toml", ".mod", ".sum": return "text/plain; charset=utf-8", true } return "", false } func humanSize(n int64) string { if n < 1024 { return fmt.Sprintf("%d B", n) } units := []string{"KB", "MB", "GB", "TB", "PB", "EB"} value := float64(n) i := -1 for value >= 1024 && i < len(units)-1 { value /= 1024 i++ } return fmt.Sprintf("%.1f %s", value, units[i]) } const pageHTML = ` {{.Title}} · 文件浏览

文件浏览

{{.Root}}
{{if .LoggedIn}}
已登录
{{else if .AdminEnabled}} 管理功能已启用 {{end}}
{{if .Notice}}
{{.Notice}}
{{end}} {{if .ConfirmName}}
{{if .DeleteIsDir}}删除文件夹{{else}}删除文件{{end}}
{{if .DeleteIsDir}}

确定要删除文件夹「{{.ConfirmName}}」及其中的所有内容吗?删除后无法恢复。

{{else}}

确定要删除「{{.ConfirmName}}」吗?删除后无法恢复。

{{end}} {{if .LoggedIn}}{{else}}
{{end}}
取消
{{end}} {{if .ConfirmRename}}
重命名

将「{{.ConfirmRename}}」重命名为:

{{if .LoggedIn}}{{else}}
{{end}}
取消
{{end}} {{if .ConfirmMany}}
批量删除({{len .ConfirmMany}} 项)
{{range .ConfirmMany}}{{end}}

确定要删除以下 {{len .ConfirmMany}} 个项目吗?{{if .ConfirmHasDir}}其中文件夹会连同其全部内容一并删除。{{end}}删除后无法恢复。

    {{range .ConfirmMany}}
  • {{.Name}}{{if .IsDir}}(文件夹){{end}}
  • {{end}}
{{if .LoggedIn}}{{else}}
{{end}}
取消
{{end}} {{if .AdminEnabled}}
新建文件夹
{{if .LoggedIn}}{{else}}
{{end}}
在当前目录下创建 · 不可包含 / 或 \
上传文件到此目录
{{if .LoggedIn}}{{else}}
{{end}}
拖拽文件或文件夹到此处,或点击选择文件
单次上限 {{.MaxUpload}} · 文件夹会保留相对路径
{{end}} {{if .IsSearch}}
返回目录
在「{{if .SearchBase}}{{.SearchBase}}{{else}}根目录{{end}}」中搜索「{{.SearchQuery}}」:扫描 {{.SearchScanned}} 项,匹配 {{len .SearchResults}} 项{{if .SearchTrunc}}(已达上限,结果可能不完整){{end}}
名称 / 相对路径大小修改时间
{{range .SearchResults}} {{if .IsDir}}
{{.Name}}{{.Rel}} {{.ModTime}}
{{else}}
{{.Name}}{{.Rel}} {{.Size}}{{.ModTime}} {{if .BrowseURL}}{{end}} {{if .PreviewURL}}{{end}}
{{end}} {{end}} {{if not .SearchResults}}
没有找到匹配的文件或文件夹
{{end}}
{{else}}
{{if .Entries}} 共 {{len .Entries}} 项 名称{{.SortName.Arrow}} 大小{{.SortSize.Arrow}} 时间{{.SortTime.Arrow}} {{end}}
{{if .Entries}} {{if .SelectAll}}取消全选{{else}}全选{{end}} 勾选文件或文件夹后可批量下载、删除、移动或复制 {{else}} 此目录暂无可批量操作的项目 {{end}} {{if .Entries}} {{if .AdminEnabled}} {{end}} {{if .AdminEnabled}}{{end}} {{end}}
{{if .ParentURL}} 上一级 {{end}} {{range .Entries}} {{if .IsDir}}
{{.Name}} {{.ModTime}} {{if .RenameURL}} {{end}} {{if .DeleteURL}} {{end}}
{{else}}
{{.Name}} {{.Size}} {{.ModTime}} {{if .PreviewURL}} {{end}} {{if .RenameURL}} {{end}} {{if .DeleteURL}} {{end}}
{{end}} {{end}} {{if not .Entries}}
此目录为空
{{end}} {{if .Entries}}{{end}}

共 {{.DirCount}} 个文件夹 · {{.FileCount}} 个文件

{{end}}
`