Rework server

This commit is contained in:
Alexander Neumann 2024-04-25 21:40:46 +02:00
parent 993eb70422
commit 8a5ac6dc13
6 changed files with 365 additions and 218 deletions

View File

@ -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 = `<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>`
const treePageTpl = `<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>`
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;}
`

View File

@ -0,0 +1,6 @@
package assets
import "embed"
//go:embed *
var FS embed.FS

View 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>

View 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;
}

View 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>

231
internal/server/server.go Normal file
View File

@ -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
}