diff --git a/changelog/unreleased/pull-4276 b/changelog/unreleased/pull-4276 new file mode 100644 index 000000000..71073052f --- /dev/null +++ b/changelog/unreleased/pull-4276 @@ -0,0 +1,13 @@ +Enhancement: Implement web server to browse snapshots + +Currently the canonical way of browsing a repository's snapshots to view +or restore files is `mount`. Unfortunately `mount` depends on fuse which +is not available on all operating systems. + +The new `restic serve` command presents a web interface to browse a +repository's snapshots. It allows to view and download files individually +or as a group (as a tar archive) from snapshots. + +https://github.com/restic/restic/pull/4276 +https://github.com/restic/restic/issues/60 + \ No newline at end of file diff --git a/cmd/restic/cmd_serve.go b/cmd/restic/cmd_serve.go new file mode 100644 index 000000000..22077baaf --- /dev/null +++ b/cmd/restic/cmd_serve.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/restic/restic/internal/errors" + "github.com/restic/restic/internal/restic" + "github.com/restic/restic/internal/server" +) + +var cmdServe = &cobra.Command{ + Use: "serve", + Short: "runs a web server to browse a repository", + Long: ` +The serve command runs a web server to browse a repository. +`, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runWebServer(cmd.Context(), serveOptions, globalOptions, args) + }, +} + +type ServeOptions struct { + Listen string +} + +var serveOptions ServeOptions + +func init() { + cmdRoot.AddCommand(cmdServe) + cmdFlags := cmdServe.Flags() + cmdFlags.StringVarP(&serveOptions.Listen, "listen", "l", "localhost:3080", "set the listen host name and `address`") +} + +const serverShutdownTimeout = 30 * time.Second + +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") + } + + ctx, repo, unlock, err := openWithReadLock(ctx, gopts, gopts.NoLock) + if err != nil { + return err + } + defer unlock() + + snapshotLister, err := restic.MemorizeList(ctx, repo, restic.SnapshotFile) + if err != nil { + return err + } + + bar := newIndexProgress(gopts.Quiet, gopts.JSON) + err = repo.LoadIndex(ctx, bar) + if err != nil { + return err + } + + handler, err := server.New(repo, snapshotLister, TimeFormat) + if err != nil { + return err + } + + srv := http.Server{ + BaseContext: func(l net.Listener) context.Context { + // just return the global context + return ctx + }, + Handler: handler, + } + + listener, err := net.Listen("tcp", opts.Listen) + if err != nil { + return fmt.Errorf("start listener: %v", err) + } + + // wait until context is cancelled, then close listener + go func() { + <-ctx.Done() + Printf("gracefully shutting down server\n") + + ctxTimeout, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + + _ = srv.Shutdown(ctxTimeout) + }() + + Printf("Now serving the repository at http://%s\n", opts.Listen) + Printf("When finished, quit with Ctrl-c here.\n") + + err = srv.Serve(listener) + + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + + if err != nil { + return fmt.Errorf("serve: %v", err) + } + + return nil +} 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..2980e9e41 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,246 @@ +// Package server contains an HTTP server which can serve content from a repo. +package server + +import ( + "context" + "embed" + "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/walker" +) + +//go:embed assets/*.html assets/*.css +var assets embed.FS + +// New returns a new HTTP server. +func New(repo restic.Repository, snapshotLister restic.Lister, timeFormat string) (http.Handler, error) { + assetsFS, err := fs.Sub(assets, "assets") + if err != nil { + return nil, fmt.Errorf("derive subdir fs for assets: %w", err) + } + + funcs := template.FuncMap{ + "FormatTime": func(time time.Time) string { return time.Format(timeFormat) }, + } + + templates := template.Must(template.New("").Funcs(funcs).ParseFS(assetsFS, "*.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) + } + }) + + mux.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) + } + }) + + mux.HandleFunc("/style.css", func(rw http.ResponseWriter, req *http.Request) { + buf, err := fs.ReadFile(assetsFS, "style.css") + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + + fmt.Fprintf(rw, "error reading embedded style.css: %v\n", err) + + return + } + + rw.Header().Set("Cache-Control", "max-age=300") + rw.Header().Set("Content-Type", "text/css") + + _, _ = rw.Write(buf) + }) + + return mux, nil +} + +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 +}