+
diff --git a/htmlui/src/BeginRestore.js b/htmlui/src/BeginRestore.js
new file mode 100644
index 000000000..98a4ca74d
--- /dev/null
+++ b/htmlui/src/BeginRestore.js
@@ -0,0 +1,133 @@
+import axios from 'axios';
+import React, { Component } from 'react';
+import Button from 'react-bootstrap/Button';
+import Form from 'react-bootstrap/Form';
+import { Link } from "react-router-dom";
+import { handleChange, RequiredBoolean, RequiredField, validateRequiredFields } from './forms';
+import { GoBackButton } from './uiutil';
+
+export class BeginRestore extends Component {
+ constructor(props) {
+ super();
+
+ this.state = {
+ incremental: true,
+ continueOnErrors: false,
+ restoreOwnership: true,
+ restorePermissions: true,
+ restoreModTimes: true,
+ uncompressedZip: true,
+ overwriteFiles: false,
+ overwriteDirectories: false,
+ overwriteSymlinks: false,
+ ignorePermissionErrors: true,
+ restoreTask: "",
+ };
+
+ this.handleChange = handleChange.bind(this);
+ this.start = this.start.bind(this);
+ }
+
+ start(e) {
+ e.preventDefault();
+
+ if (!validateRequiredFields(this, ["destination"])) {
+ return;
+ }
+
+ const dst = (this.state.destination + "");
+
+ let req = {
+ root: this.props.match.params.oid,
+ options: {
+ incremental: this.state.incremental,
+ ignoreErrors: this.state.continueOnErrors,
+ },
+ }
+
+ if (dst.endsWith(".zip")) {
+ req.zipFile = dst;
+ req.uncompressedZip = this.state.uncompressedZip;
+ } else if (dst.endsWith(".tar")) {
+ req.tarFile = dst;
+ } else {
+ req.fsOutput = {
+ targetPath: dst,
+ skipOwners: !this.state.restoreOwnership,
+ skipPermissions: !this.state.restorePermissions,
+ skipTimes: !this.state.restoreModTimes,
+
+ ignorePermissionErrors: this.state.ignorePermissionErrors,
+ overwriteFiles: this.state.overwriteFiles,
+ overwriteDirectories: this.state.overwriteDirectories,
+ overwriteSymlinks: this.state.overwriteSymlinks,
+ }
+ }
+
+ axios.post('/api/v1/restore', req).then(result => {
+ this.setState({
+ restoreTask: result.data.id,
+ })
+ this.props.history.replace("/tasks/" + result.data.id);
+ }).catch(error => {
+ if (error.response.data) {
+ alert(JSON.stringify(error.response.data));
+ } else {
+ alert('failed');
+ }
+ });
+ }
+
+ render() {
+ if (this.state.restoreTask) {
+ return
+
+ Go To Restore Task.
+
;
+ }
+
+ return
+ Restore
+
+
+ {RequiredField(this, "Destination", "destination", {
+ autoFocus: true,
+ placeholder: "enter destination path",
+ },
+ "You can also restore to a .zip or .tar file by providing the appropriate extension.")}
+
+
+ {RequiredBoolean(this, "Skip previously restored files and symlinks", "incremental")}
+
+
+ {RequiredBoolean(this, "Continue on Errors", "continueOnErrors", "When a restore error occurs, attempt to continue instead of failing fast.")}
+
+
+ {RequiredBoolean(this, "Restore File Ownership", "restoreOwnership")}
+
+
+ {RequiredBoolean(this, "Restore File Permissions", "restorePermissions")}
+
+
+ {RequiredBoolean(this, "Restore File Modification Time", "restoreModTimes")}
+
+
+ {RequiredBoolean(this, "Overwrite Files", "overwriteFiles")}
+
+
+ {RequiredBoolean(this, "Overwrite Directories", "overwriteDirectories")}
+
+
+ {RequiredBoolean(this, "Overwrite Symbolic Links", "overwriteSymlinks")}
+
+
+ {RequiredBoolean(this, "Disable ZIP compression", "uncompressedZip", "Do not compress when restoring to a ZIP file (faster).")}
+
+
+
+
+
+ ;
+ }
+}
diff --git a/htmlui/src/DirectoryObject.js b/htmlui/src/DirectoryObject.js
index 9b66b3319..e7b8bc899 100644
--- a/htmlui/src/DirectoryObject.js
+++ b/htmlui/src/DirectoryObject.js
@@ -131,6 +131,8 @@ export class DirectoryObject extends Component {
> : <>
>}
+
+
diff --git a/htmlui/src/TaskDetails.js b/htmlui/src/TaskDetails.js
index 6a35bebee..378ec69dc 100644
--- a/htmlui/src/TaskDetails.js
+++ b/htmlui/src/TaskDetails.js
@@ -117,7 +117,7 @@ export class TaskDetails extends Component {
break;
}
- return {label}: {formatted}
+ return {label}: {formatted}
}
render() {
@@ -169,7 +169,7 @@ export class TaskDetails extends Component {
- {this.state.showLog ? : }
+ {this.state.showLog ? : }
diff --git a/internal/server/api_restore.go b/internal/server/api_restore.go
new file mode 100644
index 000000000..aceca9c28
--- /dev/null
+++ b/internal/server/api_restore.go
@@ -0,0 +1,124 @@
+package server
+
+import (
+ "archive/zip"
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+
+ "github.com/pkg/errors"
+
+ "github.com/kopia/kopia/internal/ctxutil"
+ "github.com/kopia/kopia/internal/serverapi"
+ "github.com/kopia/kopia/internal/uitask"
+ "github.com/kopia/kopia/snapshot/restore"
+ "github.com/kopia/kopia/snapshot/snapshotfs"
+)
+
+func restoreCounters(s restore.Stats) map[string]uitask.CounterValue {
+ return map[string]uitask.CounterValue{
+ "Restored Files": uitask.SimpleCounter(int64(s.RestoredFileCount)),
+ "Restored Directories": uitask.SimpleCounter(int64(s.RestoredDirCount)),
+ "Restored Symlinks": uitask.SimpleCounter(int64(s.RestoredSymlinkCount)),
+ "Restored Bytes": uitask.BytesCounter(s.RestoredTotalFileSize),
+ "Ignored Errors": uitask.SimpleCounter(int64(s.IgnoredErrorCount)),
+ "Skipped Files": uitask.SimpleCounter(int64(s.SkippedCount)),
+ "Skipped Bytes": uitask.BytesCounter(s.SkippedTotalFileSize),
+ }
+}
+
+func (s *Server) handleRestore(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) {
+ var req serverapi.RestoreRequest
+
+ if err := json.Unmarshal(body, &req); err != nil {
+ return nil, requestError(serverapi.ErrorMalformedRequest, "malformed request body")
+ }
+
+ ctx = ctxutil.Detach(ctx)
+ rep := s.rep
+
+ if req.Root == "" {
+ return nil, requestError(serverapi.ErrorMalformedRequest, "root not specified")
+ }
+
+ rootEntry, err := snapshotfs.FilesystemEntryFromIDWithPath(ctx, rep, req.Root, false)
+ if err != nil {
+ return nil, internalServerError(err)
+ }
+
+ var (
+ out restore.Output
+ description string
+ )
+
+ switch {
+ case req.Filesystem != nil:
+ out = req.Filesystem
+ description = "Destination: " + req.Filesystem.TargetPath
+
+ case req.ZipFile != "":
+ f, err := os.Create(req.ZipFile)
+ if err != nil {
+ return nil, internalServerError(err)
+ }
+
+ if req.UncompressedZip {
+ out = restore.NewZipOutput(f, zip.Store)
+ description = "Uncompressed ZIP File: " + req.ZipFile
+ } else {
+ out = restore.NewZipOutput(f, zip.Deflate)
+ description = "ZIP File: " + req.ZipFile
+ }
+
+ case req.TarFile != "":
+ f, err := os.Create(req.TarFile)
+ if err != nil {
+ return nil, internalServerError(err)
+ }
+
+ out = restore.NewTarOutput(f)
+ description = "TAR File: " + req.TarFile
+
+ default:
+ return nil, requestError(serverapi.ErrorMalformedRequest, "output not specified")
+ }
+
+ taskIDChan := make(chan string)
+
+ // launch a goroutine that will continue the restore and can be observed in the Tasks UI.
+
+ // nolint:errcheck
+ go s.taskmgr.Run(ctx, "Restore", description, func(ctx context.Context, ctrl uitask.Controller) error {
+ taskIDChan <- ctrl.CurrentTaskID()
+
+ opt := req.Options
+
+ opt.ProgressCallback = func(ctx context.Context, s restore.Stats) {
+ ctrl.ReportCounters(restoreCounters(s))
+ }
+
+ cancelChan := make(chan struct{})
+ opt.Cancel = cancelChan
+
+ ctrl.OnCancel(func() {
+ close(opt.Cancel)
+ })
+
+ st, err := restore.Entry(ctx, rep, out, rootEntry, opt)
+ if err == nil {
+ ctrl.ReportCounters(restoreCounters(st))
+ }
+
+ return errors.Wrap(err, "error restoring")
+ })
+
+ taskID := <-taskIDChan
+
+ task, ok := s.taskmgr.GetTask(taskID)
+ if !ok {
+ return nil, internalServerError(errors.Errorf("task not found"))
+ }
+
+ return task, nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index d10a969a3..c76cbb9db 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -77,6 +77,7 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler {
m.HandleFunc("/api/v1/shutdown", s.handleAPIPossiblyNotConnected(s.handleShutdown)).Methods(http.MethodPost)
m.HandleFunc("/api/v1/objects/{objectID}", s.requireAuth(s.handleObjectGet)).Methods(http.MethodGet)
+ m.HandleFunc("/api/v1/restore", s.handleAPI(s.handleRestore)).Methods(http.MethodPost)
m.HandleFunc("/api/v1/repo/status", s.handleAPIPossiblyNotConnected(s.handleRepoStatus)).Methods(http.MethodGet)
m.HandleFunc("/api/v1/repo/connect", s.handleAPIPossiblyNotConnected(s.handleRepoConnect)).Methods(http.MethodPost)
diff --git a/internal/serverapi/serverapi.go b/internal/serverapi/serverapi.go
index fd5be3ff2..7222dcfff 100644
--- a/internal/serverapi/serverapi.go
+++ b/internal/serverapi/serverapi.go
@@ -12,6 +12,7 @@
"github.com/kopia/kopia/repo/object"
"github.com/kopia/kopia/snapshot"
"github.com/kopia/kopia/snapshot/policy"
+ "github.com/kopia/kopia/snapshot/restore"
"github.com/kopia/kopia/snapshot/snapshotfs"
)
@@ -200,3 +201,16 @@ type TaskListResponse struct {
type TaskLogResponse struct {
Logs []uitask.LogEntry `json:"logs"`
}
+
+// RestoreRequest contains request to restore an object (file or directory) to a given destination.
+type RestoreRequest struct {
+ Root string `json:"root"`
+
+ Filesystem *restore.FilesystemOutput `json:"fsOutput"`
+
+ ZipFile string `json:"zipFile"`
+ UncompressedZip bool `json:"uncompressedZip"`
+
+ TarFile string `json:"tarFile"`
+ Options restore.Options `json:"options"`
+}
diff --git a/snapshot/restore/local_fs_output.go b/snapshot/restore/local_fs_output.go
index cb32b1de9..04e3f9890 100644
--- a/snapshot/restore/local_fs_output.go
+++ b/snapshot/restore/local_fs_output.go
@@ -23,32 +23,32 @@
// FilesystemOutput contains the options for outputting a file system tree.
type FilesystemOutput struct {
// TargetPath for restore.
- TargetPath string
+ TargetPath string `json:"targetPath"`
// If a directory already exists, overwrite the directory.
- OverwriteDirectories bool
+ OverwriteDirectories bool `json:"overwriteDirectories"`
// Indicate whether or not to overwrite existing files. When set to false,
// the copier does not modify already existing files and returns an error
// instead.
- OverwriteFiles bool
+ OverwriteFiles bool `json:"overwriteFiles"`
// If a symlink already exists, remove it and create a new one. When set to
// false, the copier does not modify existing symlinks and will return an
// error instead.
- OverwriteSymlinks bool
+ OverwriteSymlinks bool `json:"overwriteSymlinks"`
// IgnorePermissionErrors causes restore to ignore errors due to invalid permissions.
- IgnorePermissionErrors bool
+ IgnorePermissionErrors bool `json:"ignorePermissionErrors"`
// SkipOwners when set to true causes restore to skip restoring owner information.
- SkipOwners bool
+ SkipOwners bool `json:"skipOwners"`
// SkipPermissions when set to true causes restore to skip restoring permission information.
- SkipPermissions bool
+ SkipPermissions bool `json:"skipPermissions"`
// SkipTimes when set to true causes restore to skip restoring modification times.
- SkipTimes bool
+ SkipTimes bool `json:"skipTimes"`
}
// Parallelizable implements restore.Output interface.
diff --git a/snapshot/restore/restore.go b/snapshot/restore/restore.go
index cb61e270d..d8f91bafa 100644
--- a/snapshot/restore/restore.go
+++ b/snapshot/restore/restore.go
@@ -63,10 +63,12 @@ func (s *Stats) clone() Stats {
// Options provides optional restore parameters.
type Options struct {
- Parallel int
+ Parallel int `json:"parallel"`
+ Incremental bool `json:"incremental"`
+ IgnoreErrors bool `json:"ignoreErrors"`
+
ProgressCallback func(ctx context.Context, s Stats)
- Incremental bool
- IgnoreErrors bool
+ Cancel chan struct{} // channel that can be externally closed to signal cancelation
}
// Entry walks a snapshot root with given root entry and restores it to the provided output.
@@ -76,10 +78,13 @@ func Entry(ctx context.Context, rep repo.Repository, output Output, rootEntry fs
q: parallelwork.NewQueue(),
incremental: options.Incremental,
ignoreErrors: options.IgnoreErrors,
+ cancel: options.Cancel,
}
c.q.ProgressCallback = func(ctx context.Context, enqueued, active, completed int64) {
- options.ProgressCallback(ctx, c.stats.clone())
+ if options.ProgressCallback != nil {
+ options.ProgressCallback(ctx, c.stats.clone())
+ }
}
c.q.EnqueueFront(ctx, func() error {
@@ -112,9 +117,19 @@ type copier struct {
q *parallelwork.Queue
incremental bool
ignoreErrors bool
+ cancel chan struct{}
}
func (c *copier) copyEntry(ctx context.Context, e fs.Entry, targetPath string, onCompletion func() error) error {
+ if c.cancel != nil {
+ select {
+ case <-c.cancel:
+ return onCompletion()
+
+ default:
+ }
+ }
+
if c.incremental {
// in incremental mode, do not copy if the output already exists
switch e := e.(type) {