diff --git a/cmd/restic/cmd_serve.go b/cmd/restic/cmd_serve.go index 4865a0b58..d00ce201e 100644 --- a/cmd/restic/cmd_serve.go +++ b/cmd/restic/cmd_serve.go @@ -2,19 +2,13 @@ package main import ( "context" - "html/template" "net/http" - "sort" - "strings" - "time" "github.com/spf13/cobra" - "github.com/restic/restic/internal/dump" "github.com/restic/restic/internal/errors" - "github.com/restic/restic/internal/fs" "github.com/restic/restic/internal/restic" - "github.com/restic/restic/internal/walker" + "github.com/restic/restic/internal/server" ) var cmdServe = &cobra.Command{ @@ -41,30 +35,6 @@ func init() { cmdFlags.StringVarP(&serveOptions.Listen, "listen", "l", "localhost:3080", "set the listen host name and `address`") } -type fileNode struct { - Path string - Node *restic.Node -} - -func listNodes(ctx context.Context, repo restic.Repository, tree restic.ID, path string) ([]fileNode, error) { - var files []fileNode - err := walker.Walk(ctx, repo, tree, walker.WalkVisitor{ - ProcessNode: func(_ restic.ID, nodepath string, node *restic.Node, err error) error { - if err != nil || node == nil { - return err - } - if fs.HasPathPrefix(path, nodepath) { - files = append(files, fileNode{nodepath, node}) - } - if node.Type == "dir" && !fs.HasPathPrefix(nodepath, path) { - return walker.ErrSkipNode - } - return nil - }, - }) - return files, err -} - func runWebServer(ctx context.Context, opts ServeOptions, gopts GlobalOptions, args []string) error { if len(args) > 0 { return errors.Fatal("this command does not accept additional arguments") @@ -87,195 +57,10 @@ func runWebServer(ctx context.Context, opts ServeOptions, gopts GlobalOptions, a return err } - funcMap := template.FuncMap{ - "FormatTime": func(time time.Time) string { return time.Format(TimeFormat) }, - } - indexPage := template.Must(template.New("index").Funcs(funcMap).Parse(indexPageTpl)) - treePage := template.Must(template.New("tree").Funcs(funcMap).Parse(treePageTpl)) - - http.HandleFunc("/tree/", func(w http.ResponseWriter, r *http.Request) { - snapshotID, curPath, _ := strings.Cut(r.URL.Path[6:], "/") - curPath = "/" + strings.Trim(curPath, "/") - _ = r.ParseForm() - - sn, _, err := restic.FindSnapshot(ctx, snapshotLister, repo, snapshotID) - if err != nil { - http.Error(w, "Snapshot not found: "+err.Error(), http.StatusNotFound) - return - } - - files, err := listNodes(ctx, repo, *sn.Tree, curPath) - if err != nil || len(files) == 0 { - http.Error(w, "Path not found in snapshot", http.StatusNotFound) - return - } - - if r.Form.Get("action") == "dump" { - var tree restic.Tree - for _, file := range files { - for _, name := range r.Form["name"] { - if name == file.Node.Name { - tree.Nodes = append(tree.Nodes, file.Node) - } - } - } - if len(tree.Nodes) > 0 { - filename := strings.ReplaceAll(strings.Trim(snapshotID+curPath, "/"), "/", "_") + ".tar.gz" - w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") - // For now it's hardcoded to tar because it's the only format that supports all node types correctly - if err := dump.New("tar", repo, w).DumpTree(ctx, &tree, "/"); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - } - - if len(files) == 1 && files[0].Node.Type == "file" { - if err := dump.New("zip", repo, w).WriteNode(ctx, files[0].Node); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - var rows []treePageRow - for _, item := range files { - if item.Path != curPath { - rows = append(rows, treePageRow{ - Link: "/tree/" + snapshotID + item.Path, - Name: item.Node.Name, - Type: item.Node.Type, - Size: item.Node.Size, - Time: item.Node.ModTime, - }) - } - } - sort.SliceStable(rows, func(i, j int) bool { - return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name) - }) - sort.SliceStable(rows, func(i, j int) bool { - return rows[i].Type == "dir" && rows[j].Type != "dir" - }) - parent := "/tree/" + snapshotID + curPath + "/.." - if curPath == "/" { - parent = "/" - } - if err := treePage.Execute(w, treePageData{snapshotID + ": " + curPath, parent, rows}); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - var rows []indexPageRow - for sn := range FindFilteredSnapshots(ctx, snapshotLister, repo, &restic.SnapshotFilter{}, nil) { - rows = append(rows, indexPageRow{ - Link: "/tree/" + sn.ID().Str() + "/", - ID: sn.ID().Str(), - Time: sn.Time, - Host: sn.Hostname, - Tags: sn.Tags, - Paths: sn.Paths, - }) - } - sort.Slice(rows, func(i, j int) bool { - return rows[i].Time.After(rows[j].Time) - }) - if err := indexPage.Execute(w, indexPageData{"Snapshots", rows}); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) - - http.HandleFunc("/style.css", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Cache-Control", "max-age=300") - _, _ = w.Write([]byte(stylesheetTxt)) - }) + srv := server.New(repo, snapshotLister, TimeFormat) Printf("Now serving the repository at http://%s\n", opts.Listen) Printf("When finished, quit with Ctrl-c here.\n") - return http.ListenAndServe(opts.Listen, nil) + return http.ListenAndServe(opts.Listen, srv) } - -type indexPageRow struct { - Link string - ID string - Time time.Time - Host string - Tags []string - Paths []string -} - -type indexPageData struct { - Title string - Rows []indexPageRow -} - -type treePageRow struct { - Link string - Name string - Type string - Size uint64 - Time time.Time -} - -type treePageData struct { - Title string - Parent string - Rows []treePageRow -} - -const indexPageTpl = ` - - -{{.Title}} :: restic - - -

{{.Title}}

- - - -{{range .Rows}} - -{{end}} - -
IDTimeHostTagsPaths
{{.ID}}{{.Time | FormatTime}}{{.Host}}{{.Tags}}{{.Paths}}
- -` - -const treePageTpl = ` - - -{{.Title}} :: restic - - -

{{.Title}}

-
- - - -{{if .Parent}}{{end}} -{{range .Rows}} - -{{end}} - - - - -
NameTypeSizeDate modified
..parent
{{.Name}}{{.Type}}{{.Size}}{{.Time | FormatTime}}
-
- -` - -const stylesheetTxt = ` -h1,h2,h3 {text-align:center; margin: 0.5em;} -table {margin: 0 auto;border-collapse: collapse; } -thead th {text-align: left; font-weight: bold;} -tbody.content tr:hover {background: #eee;} -tbody.content a.file:before {content: '\1F4C4'} -tbody.content a.dir:before {content: '\1F4C1'} -tbody.actions td {padding:.5em;} -table, td, tr, th { border: 1px solid black; padding: .1em .5em;} -` diff --git a/internal/server/assets/fs.go b/internal/server/assets/fs.go new file mode 100644 index 000000000..ed1942d08 --- /dev/null +++ b/internal/server/assets/fs.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed * +var FS embed.FS diff --git a/internal/server/assets/index.html b/internal/server/assets/index.html new file mode 100644 index 000000000..c40677252 --- /dev/null +++ b/internal/server/assets/index.html @@ -0,0 +1,34 @@ + + + + + {{.Title}} :: restic + + + +

{{.Title}}

+ + + + + + + + + + + + {{range .Rows}} + + + + + + + + {{end}} + +
IDTimeHostTagsPaths
{{.ID}}{{.Time | FormatTime}}{{.Host}}{{.Tags}}{{.Paths}}
+ + + \ No newline at end of file diff --git a/internal/server/assets/style.css b/internal/server/assets/style.css new file mode 100644 index 000000000..79a87bcb4 --- /dev/null +++ b/internal/server/assets/style.css @@ -0,0 +1,40 @@ +h1, +h2, +h3 { + text-align: center; + margin: 0.5em; +} + +table { + margin: 0 auto; + border-collapse: collapse; +} + +thead th { + text-align: left; + font-weight: bold; +} + +tbody.content tr:hover { + background: #eee; +} + +tbody.content a.file:before { + content: '\1F4C4' +} + +tbody.content a.dir:before { + content: '\1F4C1' +} + +tbody.actions td { + padding: .5em; +} + +table, +td, +tr, +th { + border: 1px solid black; + padding: .1em .5em; +} \ No newline at end of file diff --git a/internal/server/assets/tree.html b/internal/server/assets/tree.html new file mode 100644 index 000000000..174613df4 --- /dev/null +++ b/internal/server/assets/tree.html @@ -0,0 +1,51 @@ + + + + + {{.Title}} :: restic + + + +

{{.Title}}

+
+ + + + + + + + + + + + {{if .Parent}} + + + + + {{end}} + {{range .Rows}} + + + + + + + + + {{end}} + + + + + + +
+ NameTypeSizeDate modified
..parent +
{{.Name}}{{.Type}}{{.Size}}{{.Time | FormatTime}}
+
+ + + \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..77d02e7cd --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,231 @@ +// Package server contains an HTTP server which can serve content from a repo. +package server + +import ( + "context" + "fmt" + "io/fs" + "net/http" + "sort" + "strings" + "text/template" + "time" + + "github.com/restic/restic/internal/dump" + rfs "github.com/restic/restic/internal/fs" + "github.com/restic/restic/internal/restic" + "github.com/restic/restic/internal/server/assets" + "github.com/restic/restic/internal/walker" +) + +// New returns a new HTTP server. +func New(repo restic.Repository, snapshotLister restic.Lister, timeFormat string) http.Handler { + funcs := template.FuncMap{ + "FormatTime": func(time time.Time) string { return time.Format(timeFormat) }, + } + + templates := template.Must(template.New("").Funcs(funcs).ParseFS(assets.FS, "*.html")) + + mux := http.NewServeMux() + + indexPage := templates.Lookup("index.html") + if indexPage == nil { + panic("index.html not found") + } + + treePage := templates.Lookup("tree.html") + if treePage == nil { + panic("tree.html not found") + } + + mux.HandleFunc("/tree/", func(rw http.ResponseWriter, req *http.Request) { + snapshotID, curPath, _ := strings.Cut(req.URL.Path[6:], "/") + curPath = "/" + strings.Trim(curPath, "/") + _ = req.ParseForm() + + sn, _, err := restic.FindSnapshot(req.Context(), snapshotLister, repo, snapshotID) + if err != nil { + http.Error(rw, "Snapshot not found: "+err.Error(), http.StatusNotFound) + return + } + + files, err := listNodes(req.Context(), repo, *sn.Tree, curPath) + if err != nil || len(files) == 0 { + http.Error(rw, "Path not found in snapshot", http.StatusNotFound) + return + } + + if req.Form.Get("action") == "dump" { + var tree restic.Tree + for _, file := range files { + for _, name := range req.Form["name"] { + if name == file.Node.Name { + tree.Nodes = append(tree.Nodes, file.Node) + } + } + } + if len(tree.Nodes) > 0 { + filename := strings.ReplaceAll(strings.Trim(snapshotID+curPath, "/"), "/", "_") + ".tar.gz" + rw.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + // For now it's hardcoded to tar because it's the only format that supports all node types correctly + if err := dump.New("tar", repo, rw).DumpTree(req.Context(), &tree, "/"); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + return + } + } + + if len(files) == 1 && files[0].Node.Type == "file" { + if err := dump.New("zip", repo, rw).WriteNode(req.Context(), files[0].Node); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + return + } + + var rows []treePageRow + for _, item := range files { + if item.Path != curPath { + rows = append(rows, treePageRow{ + Link: "/tree/" + snapshotID + item.Path, + Name: item.Node.Name, + Type: item.Node.Type, + Size: item.Node.Size, + Time: item.Node.ModTime, + }) + } + } + sort.SliceStable(rows, func(i, j int) bool { + return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name) + }) + sort.SliceStable(rows, func(i, j int) bool { + return rows[i].Type == "dir" && rows[j].Type != "dir" + }) + parent := "/tree/" + snapshotID + curPath + "/.." + if curPath == "/" { + parent = "/" + } + if err := treePage.Execute(rw, treePageData{snapshotID + ": " + curPath, parent, rows}); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + }) + + http.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" { + http.NotFound(rw, req) + return + } + var rows []indexPageRow + for sn := range findFilteredSnapshots(req.Context(), snapshotLister, repo, &restic.SnapshotFilter{}, nil) { + rows = append(rows, indexPageRow{ + Link: "/tree/" + sn.ID().Str() + "/", + ID: sn.ID().Str(), + Time: sn.Time, + Host: sn.Hostname, + Tags: sn.Tags, + Paths: sn.Paths, + }) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Time.After(rows[j].Time) + }) + if err := indexPage.Execute(rw, indexPageData{"Snapshots", rows}); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + }) + + http.HandleFunc("/style.css", func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Cache-Control", "max-age=300") + buf, err := fs.ReadFile(assets.FS, "style.css") + if err == nil { + rw.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(rw, "error: %v", err) + return + } + + _, _ = rw.Write(buf) + }) + + return mux +} + +type fileNode struct { + Path string + Node *restic.Node +} + +func listNodes(ctx context.Context, repo restic.Repository, tree restic.ID, path string) ([]fileNode, error) { + var files []fileNode + err := walker.Walk(ctx, repo, tree, walker.WalkVisitor{ + ProcessNode: func(_ restic.ID, nodepath string, node *restic.Node, err error) error { + if err != nil || node == nil { + return err + } + if rfs.HasPathPrefix(path, nodepath) { + files = append(files, fileNode{nodepath, node}) + } + if node.Type == "dir" && !rfs.HasPathPrefix(nodepath, path) { + return walker.ErrSkipNode + } + return nil + }, + }) + return files, err +} + +type indexPageRow struct { + Link string + ID string + Time time.Time + Host string + Tags []string + Paths []string +} + +type indexPageData struct { + Title string + Rows []indexPageRow +} + +type treePageRow struct { + Link string + Name string + Type string + Size uint64 + Time time.Time +} + +type treePageData struct { + Title string + Parent string + Rows []treePageRow +} + +// findFilteredSnapshots yields Snapshots, either given explicitly by `snapshotIDs` or filtered from the list of all snapshots. +func findFilteredSnapshots(ctx context.Context, be restic.Lister, loader restic.LoaderUnpacked, f *restic.SnapshotFilter, snapshotIDs []string) <-chan *restic.Snapshot { + out := make(chan *restic.Snapshot) + go func() { + defer close(out) + be, err := restic.MemorizeList(ctx, be, restic.SnapshotFile) + if err != nil { + // Warnf("could not load snapshots: %v\n", err) + return + } + + err = f.FindAll(ctx, be, loader, snapshotIDs, func(id string, sn *restic.Snapshot, err error) error { + if err != nil { + // Warnf("Ignoring %q: %v\n", id, err) + } else { + select { + case <-ctx.Done(): + return ctx.Err() + case out <- sn: + } + } + return nil + }) + if err != nil { + // Warnf("could not load snapshots: %v\n", err) + } + }() + return out +}