mirror of
https://github.com/restic/restic.git
synced 2025-03-09 00:00:02 +01:00
Merge c374a4e542
into de9a040d27
This commit is contained in:
commit
81cc3f8eb1
6 changed files with 492 additions and 0 deletions
13
changelog/unreleased/pull-4276
Normal file
13
changelog/unreleased/pull-4276
Normal file
|
@ -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
|
||||
|
108
cmd/restic/cmd_serve.go
Normal file
108
cmd/restic/cmd_serve.go
Normal file
|
@ -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
|
||||
}
|
34
internal/server/assets/index.html
Normal file
34
internal/server/assets/index.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<title>{{.Title}} :: restic</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>{{.Title}}</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Time</th>
|
||||
<th>Host</th>
|
||||
<th>Tags</th>
|
||||
<th>Paths</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td><a href="{{.Link}}">{{.ID}}</a></td>
|
||||
<td>{{.Time | FormatTime}}</td>
|
||||
<td>{{.Host}}</td>
|
||||
<td>{{.Tags}}</td>
|
||||
<td>{{.Paths}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
40
internal/server/assets/style.css
Normal file
40
internal/server/assets/style.css
Normal file
|
@ -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;
|
||||
}
|
51
internal/server/assets/tree.html
Normal file
51
internal/server/assets/tree.html
Normal file
|
@ -0,0 +1,51 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<title>{{.Title}} :: restic</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>{{.Title}}</h1>
|
||||
<form method="post">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox"
|
||||
onclick="document.querySelectorAll('.content input[type=checkbox]').forEach(cb => cb.checked = this.checked)">
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Date modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="content">
|
||||
{{if .Parent}}<tr>
|
||||
<td></td>
|
||||
<td><a href="{{.Parent}}">..</a></td>
|
||||
<td>parent</td>
|
||||
<td></td>
|
||||
<td>
|
||||
</tr>{{end}}
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="name" value="{{.Name}}"></td>
|
||||
<td><a class="{{.Type}}" href="{{.Link}}">{{.Name}}</a></td>
|
||||
<td>{{.Type}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.Time | FormatTime}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
<tbody class="actions">
|
||||
<tr>
|
||||
<td colspan="100"><button name="action" value="dump" type="submit">Download selection</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
</html>
|
246
internal/server/server.go
Normal file
246
internal/server/server.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Add table
Reference in a new issue