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..80309b539 --- /dev/null +++ b/cmd/restic/cmd_serve.go @@ -0,0 +1,268 @@ +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" +) + +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`") +} + +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, nil, func(_ restic.ID, nodepath string, node *restic.Node, err error) (bool, error) { + if err != nil || node == nil { + return false, err + } + if fs.HasPathPrefix(path, nodepath) { + files = append(files, fileNode{nodepath, node}) + } + if node.Type == "dir" && !fs.HasPathPrefix(nodepath, path) { + return false, walker.ErrSkipNode + } + return false, 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") + } + + repo, err := OpenRepository(ctx, gopts) + if err != nil { + return err + } + + if !gopts.NoLock { + var lock *restic.Lock + lock, ctx, err = lockRepo(ctx, repo) + defer unlockRepo(lock) + if err != nil { + return err + } + } + + err = repo.LoadIndex(ctx) + if err != nil { + 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, repo.Backend(), 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{"/tree/" + snapshotID + item.Path, item.Node.Name, item.Node.Type, item.Node.Size, 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, repo.Backend(), repo, &restic.SnapshotFilter{}, nil) { + rows = append(rows, indexPageRow{"/tree/" + sn.ID().Str() + "/", sn.ID().Str(), sn.Time, sn.Hostname, sn.Tags, 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, r *http.Request) { + w.Header().Set("Cache-Control", "max-age=300") + _, _ = w.Write([]byte(stylesheetTxt)) + }) + + 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) +} + +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;} +`