diff --git a/chfs-ng/go.mod b/chfs-ng/go.mod index bef9fda..3668e8e 100644 --- a/chfs-ng/go.mod +++ b/chfs-ng/go.mod @@ -7,6 +7,7 @@ require ( github.com/anthonynsimon/bild v0.14.0 github.com/emersion/go-webdav v0.7.0 github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) require golang.org/x/sys v0.3.0 // indirect diff --git a/chfs-ng/go.sum b/chfs-ng/go.sum index ab668a5..6c1864d 100644 --- a/chfs-ng/go.sum +++ b/chfs-ng/go.sum @@ -8,6 +8,8 @@ github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3T github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index ebf9829..fb8b9a9 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -7,6 +7,7 @@ import ( _ "embed" "encoding/json" "fmt" + "github.com/skip2/go-qrcode" "html" "image" _ "image/gif" @@ -85,6 +86,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/chfs/upload2", s.upload2) mux.HandleFunc("/chfs/search", s.search) mux.HandleFunc("/chfs/downloaddir/", s.downloadDir) + mux.HandleFunc("/chfs/downloadzip", s.downloadSelectedZip) + mux.HandleFunc("/chfs/qrcode", s.qrcode) mux.HandleFunc("/chfs/shared", s.shared) mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/preview", s.preview) @@ -1093,6 +1096,100 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, p string, att http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } +func (s *Server) qrcode(w http.ResponseWriter, r *http.Request) { + u := r.URL.Query().Get("url") + if u == "" || len(u) > 4096 { + writeJSONError(w, http.StatusBadRequest, "invalid url") + return + } + data, err := qrcode.Encode(u, qrcode.Medium, 320) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "qrcode failed") + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(data) +} + +func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + paths := r.URL.Query()["path"] + if len(paths) == 0 || len(paths) > 100 { + writeJSONError(w, http.StatusBadRequest, "invalid selection") + return + } + seen := map[string]bool{} + for i, raw := range paths { + p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") + if p == "" || unsafePath(p) || seen[p] { + writeJSONError(w, http.StatusBadRequest, "invalid selection") + return + } + seen[p] = true + paths[i] = p + if !a.MaskFor(p).HasRead() { + writeJSONError(w, http.StatusForbidden, "has no privilege") + return + } + if _, err := s.Store.Stat(p); err != nil { + http.NotFound(w, r) + return + } + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) + zw := zip.NewWriter(w) + defer zw.Close() + added := map[string]bool{} + var walk func(string, string) error + walk = func(p, prefix string) error { + fi, err := s.Store.Stat(p) + if err != nil { + return err + } + if fi.IsDir() { + items, err := s.Store.List(p) + if err != nil { + return err + } + for _, it := range items { + child := path.Join(p, it.Name()) + if !a.MaskFor(child).HasRead() { + continue + } + if err := walk(child, path.Join(prefix, it.Name())); err != nil { + return err + } + } + return nil + } + if added[prefix] { + return nil + } + added[prefix] = true + f, err := s.Store.Open(p) + if err != nil { + return err + } + defer f.Close() + h := &zip.FileHeader{Name: prefix, Method: zip.Deflate} + h.SetModTime(fi.ModTime()) + wz, err := zw.CreateHeader(h) + if err != nil { + return err + } + _, err = io.Copy(wz, f) + return err + } + for _, p := range paths { + if err := walk(p, path.Base(p)); err != nil { + + return + } + } +} + func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawPath := strings.TrimPrefix(r.URL.Path, "/chfs/downloaddir/") diff --git a/chfs-ng/internal/api/web/index.html b/chfs-ng/internal/api/web/index.html index e3a5195..63b924b 100644 --- a/chfs-ng/internal/api/web/index.html +++ b/chfs-ng/internal/api/web/index.html @@ -21,7 +21,7 @@ body{background:var(--bg);color:var(--text);transition:background .2s,color .2s} button.ghost{background:var(--surface2);color:var(--text)}button.danger{background:var(--danger)}.meta,.path{color:var(--muted)} .card{box-shadow:var(--shadow)}th{background:var(--surface2);color:var(--muted)}th,td{border-color:var(--border)}.field input,.toolbar input,.field select{background:var(--surface);color:var(--text);border-color:var(--border)} .iconbtn{width:38px;height:38px;padding:0;background:var(--surface2);color:var(--text)}.viewbtn.active{background:var(--primary);color:#fff}.selectbox{width:18px;height:18px;accent-color:var(--primary)} -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.drop-active{border-color:var(--primary)!important;background:var(--surface2)!important}.qr{width:min(320px,80vw);aspect-ratio:1;margin:auto;display:block;border-radius:12px;background:#fff}.drop-hint{color:var(--muted);font-size:13px;margin-top:7px}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)}
@@ -31,11 +31,12 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou