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}}
-
-ID | Time | Host | Tags | Paths |
-
-{{range .Rows}}
-{{.ID}} | {{.Time | FormatTime}} | {{.Host}} | {{.Tags}} | {{.Paths}} |
-{{end}}
-
-
-
-`
-
-const treePageTpl = `
-
-
-{{.Title}} :: restic
-
-
-{{.Title}}
-
-
-`
-
-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}}
+
+
+
+ ID |
+ Time |
+ Host |
+ Tags |
+ Paths |
+
+
+
+ {{range .Rows}}
+
+ {{.ID}} |
+ {{.Time | FormatTime}} |
+ {{.Host}} |
+ {{.Tags}} |
+ {{.Paths}} |
+
+ {{end}}
+
+
+
+
+
\ 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}}
+
+
+
+
\ 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
+}