mirror of
https://github.com/octoleo/restic.git
synced 2024-11-22 04:45:15 +00:00
commit
23e1b4bbb1
5
changelog/unreleased/pull-4573
Normal file
5
changelog/unreleased/pull-4573
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
Enhancement: Add `--new-host` and `--new-time` options to `rewrite` command
|
||||||
|
|
||||||
|
`restic rewrite` now allows rewriting the host and / or time metadata of a snapshot.
|
||||||
|
|
||||||
|
https://github.com/restic/restic/pull/4573
|
@ -148,7 +148,7 @@ func runRepairSnapshots(ctx context.Context, gopts GlobalOptions, opts RepairOpt
|
|||||||
changed, err := filterAndReplaceSnapshot(ctx, repo, sn,
|
changed, err := filterAndReplaceSnapshot(ctx, repo, sn,
|
||||||
func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
||||||
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
||||||
}, opts.DryRun, opts.Forget, "repaired")
|
}, opts.DryRun, opts.Forget, nil, "repaired")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Fatalf("unable to rewrite snapshot ID %q: %v", sn.ID().Str(), err)
|
return errors.Fatalf("unable to rewrite snapshot ID %q: %v", sn.ID().Str(), err)
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@ -46,11 +47,42 @@ Exit status is 0 if the command was successful, and non-zero if there was any er
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type snapshotMetadata struct {
|
||||||
|
Hostname string
|
||||||
|
Time *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotMetadataArgs struct {
|
||||||
|
Hostname string
|
||||||
|
Time string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sma snapshotMetadataArgs) empty() bool {
|
||||||
|
return sma.Hostname == "" && sma.Time == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sma snapshotMetadataArgs) convert() (*snapshotMetadata, error) {
|
||||||
|
if sma.empty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeStamp *time.Time
|
||||||
|
if sma.Time != "" {
|
||||||
|
t, err := time.ParseInLocation(TimeFormat, sma.Time, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Fatalf("error in time option: %v\n", err)
|
||||||
|
}
|
||||||
|
timeStamp = &t
|
||||||
|
}
|
||||||
|
return &snapshotMetadata{Hostname: sma.Hostname, Time: timeStamp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RewriteOptions collects all options for the rewrite command.
|
// RewriteOptions collects all options for the rewrite command.
|
||||||
type RewriteOptions struct {
|
type RewriteOptions struct {
|
||||||
Forget bool
|
Forget bool
|
||||||
DryRun bool
|
DryRun bool
|
||||||
|
|
||||||
|
Metadata snapshotMetadataArgs
|
||||||
restic.SnapshotFilter
|
restic.SnapshotFilter
|
||||||
excludePatternOptions
|
excludePatternOptions
|
||||||
}
|
}
|
||||||
@ -63,11 +95,15 @@ func init() {
|
|||||||
f := cmdRewrite.Flags()
|
f := cmdRewrite.Flags()
|
||||||
f.BoolVarP(&rewriteOptions.Forget, "forget", "", false, "remove original snapshots after creating new ones")
|
f.BoolVarP(&rewriteOptions.Forget, "forget", "", false, "remove original snapshots after creating new ones")
|
||||||
f.BoolVarP(&rewriteOptions.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
|
f.BoolVarP(&rewriteOptions.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done")
|
||||||
|
f.StringVar(&rewriteOptions.Metadata.Hostname, "new-host", "", "replace hostname")
|
||||||
|
f.StringVar(&rewriteOptions.Metadata.Time, "new-time", "", "replace time of the backup")
|
||||||
|
|
||||||
initMultiSnapshotFilter(f, &rewriteOptions.SnapshotFilter, true)
|
initMultiSnapshotFilter(f, &rewriteOptions.SnapshotFilter, true)
|
||||||
initExcludePatternOptions(f, &rewriteOptions.excludePatternOptions)
|
initExcludePatternOptions(f, &rewriteOptions.excludePatternOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rewriteFilterFunc func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error)
|
||||||
|
|
||||||
func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, opts RewriteOptions) (bool, error) {
|
func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, opts RewriteOptions) (bool, error) {
|
||||||
if sn.Tree == nil {
|
if sn.Tree == nil {
|
||||||
return false, errors.Errorf("snapshot %v has nil tree", sn.ID().Str())
|
return false, errors.Errorf("snapshot %v has nil tree", sn.ID().Str())
|
||||||
@ -78,33 +114,50 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *resti
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
selectByName := func(nodepath string) bool {
|
metadata, err := opts.Metadata.convert()
|
||||||
for _, reject := range rejectByNameFuncs {
|
|
||||||
if reject(nodepath) {
|
if err != nil {
|
||||||
return false
|
return false, err
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rewriter := walker.NewTreeRewriter(walker.RewriteOpts{
|
var filter rewriteFilterFunc
|
||||||
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
|
||||||
if selectByName(path) {
|
if len(rejectByNameFuncs) > 0 {
|
||||||
return node
|
selectByName := func(nodepath string) bool {
|
||||||
|
for _, reject := range rejectByNameFuncs {
|
||||||
|
if reject(nodepath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Verbosef(fmt.Sprintf("excluding %s\n", path))
|
return true
|
||||||
return nil
|
}
|
||||||
},
|
|
||||||
DisableNodeCache: true,
|
rewriter := walker.NewTreeRewriter(walker.RewriteOpts{
|
||||||
})
|
RewriteNode: func(node *restic.Node, path string) *restic.Node {
|
||||||
|
if selectByName(path) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
Verbosef(fmt.Sprintf("excluding %s\n", path))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DisableNodeCache: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
filter = func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
||||||
|
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filter = func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
||||||
|
return *sn.Tree, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return filterAndReplaceSnapshot(ctx, repo, sn,
|
return filterAndReplaceSnapshot(ctx, repo, sn,
|
||||||
func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error) {
|
filter, opts.DryRun, opts.Forget, metadata, "rewrite")
|
||||||
return rewriter.RewriteTree(ctx, repo, "/", *sn.Tree)
|
|
||||||
}, opts.DryRun, opts.Forget, "rewrite")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *restic.Snapshot, filter func(ctx context.Context, sn *restic.Snapshot) (restic.ID, error), dryRun bool, forget bool, addTag string) (bool, error) {
|
func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *restic.Snapshot,
|
||||||
|
filter rewriteFilterFunc, dryRun bool, forget bool, newMetadata *snapshotMetadata, addTag string) (bool, error) {
|
||||||
|
|
||||||
wg, wgCtx := errgroup.WithContext(ctx)
|
wg, wgCtx := errgroup.WithContext(ctx)
|
||||||
repo.StartPackUploader(wgCtx, wg)
|
repo.StartPackUploader(wgCtx, wg)
|
||||||
@ -138,7 +191,7 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *r
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if filteredTree == *sn.Tree {
|
if filteredTree == *sn.Tree && newMetadata == nil {
|
||||||
debug.Log("Snapshot %v not modified", sn)
|
debug.Log("Snapshot %v not modified", sn)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@ -151,6 +204,14 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *r
|
|||||||
Verbosef("would remove old snapshot\n")
|
Verbosef("would remove old snapshot\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newMetadata != nil && newMetadata.Time != nil {
|
||||||
|
Verbosef("would set time to %s\n", newMetadata.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newMetadata != nil && newMetadata.Hostname != "" {
|
||||||
|
Verbosef("would set time to %s\n", newMetadata.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +223,16 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *r
|
|||||||
sn.AddTags([]string{addTag})
|
sn.AddTags([]string{addTag})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newMetadata != nil && newMetadata.Time != nil {
|
||||||
|
Verbosef("setting time to %s\n", *newMetadata.Time)
|
||||||
|
sn.Time = *newMetadata.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
if newMetadata != nil && newMetadata.Hostname != "" {
|
||||||
|
Verbosef("setting host to %s\n", newMetadata.Hostname)
|
||||||
|
sn.Hostname = newMetadata.Hostname
|
||||||
|
}
|
||||||
|
|
||||||
// Save the new snapshot.
|
// Save the new snapshot.
|
||||||
id, err := restic.SaveSnapshot(ctx, repo, sn)
|
id, err := restic.SaveSnapshot(ctx, repo, sn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -181,8 +252,8 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runRewrite(ctx context.Context, opts RewriteOptions, gopts GlobalOptions, args []string) error {
|
func runRewrite(ctx context.Context, opts RewriteOptions, gopts GlobalOptions, args []string) error {
|
||||||
if opts.excludePatternOptions.Empty() {
|
if opts.excludePatternOptions.Empty() && opts.Metadata.empty() {
|
||||||
return errors.Fatal("Nothing to do: no excludes provided")
|
return errors.Fatal("Nothing to do: no excludes provided and no new metadata provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
repo, err := OpenRepository(ctx, gopts)
|
repo, err := OpenRepository(ctx, gopts)
|
||||||
|
@ -9,12 +9,13 @@ import (
|
|||||||
rtest "github.com/restic/restic/internal/test"
|
rtest "github.com/restic/restic/internal/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testRunRewriteExclude(t testing.TB, gopts GlobalOptions, excludes []string, forget bool) {
|
func testRunRewriteExclude(t testing.TB, gopts GlobalOptions, excludes []string, forget bool, metadata snapshotMetadataArgs) {
|
||||||
opts := RewriteOptions{
|
opts := RewriteOptions{
|
||||||
excludePatternOptions: excludePatternOptions{
|
excludePatternOptions: excludePatternOptions{
|
||||||
Excludes: excludes,
|
Excludes: excludes,
|
||||||
},
|
},
|
||||||
Forget: forget,
|
Forget: forget,
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
rtest.OK(t, runRewrite(context.TODO(), opts, gopts, nil))
|
rtest.OK(t, runRewrite(context.TODO(), opts, gopts, nil))
|
||||||
@ -38,7 +39,7 @@ func TestRewrite(t *testing.T) {
|
|||||||
createBasicRewriteRepo(t, env)
|
createBasicRewriteRepo(t, env)
|
||||||
|
|
||||||
// exclude some data
|
// exclude some data
|
||||||
testRunRewriteExclude(t, env.gopts, []string{"3"}, false)
|
testRunRewriteExclude(t, env.gopts, []string{"3"}, false, snapshotMetadataArgs{Hostname: "", Time: ""})
|
||||||
snapshotIDs := testRunList(t, "snapshots", env.gopts)
|
snapshotIDs := testRunList(t, "snapshots", env.gopts)
|
||||||
rtest.Assert(t, len(snapshotIDs) == 2, "expected two snapshots, got %v", snapshotIDs)
|
rtest.Assert(t, len(snapshotIDs) == 2, "expected two snapshots, got %v", snapshotIDs)
|
||||||
testRunCheck(t, env.gopts)
|
testRunCheck(t, env.gopts)
|
||||||
@ -50,7 +51,7 @@ func TestRewriteUnchanged(t *testing.T) {
|
|||||||
snapshotID := createBasicRewriteRepo(t, env)
|
snapshotID := createBasicRewriteRepo(t, env)
|
||||||
|
|
||||||
// use an exclude that will not exclude anything
|
// use an exclude that will not exclude anything
|
||||||
testRunRewriteExclude(t, env.gopts, []string{"3dflkhjgdflhkjetrlkhjgfdlhkj"}, false)
|
testRunRewriteExclude(t, env.gopts, []string{"3dflkhjgdflhkjetrlkhjgfdlhkj"}, false, snapshotMetadataArgs{Hostname: "", Time: ""})
|
||||||
newSnapshotIDs := testRunList(t, "snapshots", env.gopts)
|
newSnapshotIDs := testRunList(t, "snapshots", env.gopts)
|
||||||
rtest.Assert(t, len(newSnapshotIDs) == 1, "expected one snapshot, got %v", newSnapshotIDs)
|
rtest.Assert(t, len(newSnapshotIDs) == 1, "expected one snapshot, got %v", newSnapshotIDs)
|
||||||
rtest.Assert(t, snapshotID == newSnapshotIDs[0], "snapshot id changed unexpectedly")
|
rtest.Assert(t, snapshotID == newSnapshotIDs[0], "snapshot id changed unexpectedly")
|
||||||
@ -63,11 +64,44 @@ func TestRewriteReplace(t *testing.T) {
|
|||||||
snapshotID := createBasicRewriteRepo(t, env)
|
snapshotID := createBasicRewriteRepo(t, env)
|
||||||
|
|
||||||
// exclude some data
|
// exclude some data
|
||||||
testRunRewriteExclude(t, env.gopts, []string{"3"}, true)
|
testRunRewriteExclude(t, env.gopts, []string{"3"}, true, snapshotMetadataArgs{Hostname: "", Time: ""})
|
||||||
newSnapshotIDs := testRunList(t, "snapshots", env.gopts)
|
newSnapshotIDs := testListSnapshots(t, env.gopts, 1)
|
||||||
rtest.Assert(t, len(newSnapshotIDs) == 1, "expected one snapshot, got %v", newSnapshotIDs)
|
|
||||||
rtest.Assert(t, snapshotID != newSnapshotIDs[0], "snapshot id should have changed")
|
rtest.Assert(t, snapshotID != newSnapshotIDs[0], "snapshot id should have changed")
|
||||||
// check forbids unused blobs, thus remove them first
|
// check forbids unused blobs, thus remove them first
|
||||||
testRunPrune(t, env.gopts, PruneOptions{MaxUnused: "0"})
|
testRunPrune(t, env.gopts, PruneOptions{MaxUnused: "0"})
|
||||||
testRunCheck(t, env.gopts)
|
testRunCheck(t, env.gopts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRewriteMetadata(t *testing.T, metadata snapshotMetadataArgs) {
|
||||||
|
env, cleanup := withTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
createBasicRewriteRepo(t, env)
|
||||||
|
testRunRewriteExclude(t, env.gopts, []string{}, true, metadata)
|
||||||
|
|
||||||
|
repo, _ := OpenRepository(context.TODO(), env.gopts)
|
||||||
|
snapshots, err := restic.TestLoadAllSnapshots(context.TODO(), repo, nil)
|
||||||
|
rtest.OK(t, err)
|
||||||
|
rtest.Assert(t, len(snapshots) == 1, "expected one snapshot, got %v", len(snapshots))
|
||||||
|
newSnapshot := snapshots[0]
|
||||||
|
|
||||||
|
if metadata.Time != "" {
|
||||||
|
rtest.Assert(t, newSnapshot.Time.Format(TimeFormat) == metadata.Time, "New snapshot should have time %s", metadata.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Hostname != "" {
|
||||||
|
rtest.Assert(t, newSnapshot.Hostname == metadata.Hostname, "New snapshot should have host %s", metadata.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteMetadata(t *testing.T) {
|
||||||
|
newHost := "new host"
|
||||||
|
newTime := "1999-01-01 11:11:11"
|
||||||
|
|
||||||
|
for _, metadata := range []snapshotMetadataArgs{
|
||||||
|
{Hostname: "", Time: newTime},
|
||||||
|
{Hostname: newHost, Time: ""},
|
||||||
|
{Hostname: newHost, Time: newTime},
|
||||||
|
} {
|
||||||
|
testRewriteMetadata(t, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -234,6 +234,27 @@ modifying the repository. Instead restic will only print the actions it would
|
|||||||
perform.
|
perform.
|
||||||
|
|
||||||
|
|
||||||
|
Modifying metadata of snapshots
|
||||||
|
===============================
|
||||||
|
|
||||||
|
Sometimes it may be desirable to change the metadata of an existing snapshot.
|
||||||
|
Currently, rewriting the hostname and the time of the backup is supported.
|
||||||
|
This is possible using the ``rewrite`` command with the option ``--new-host`` followed by the desired new hostname or the option ``--new-time`` followed by the desired new timestamp.
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
$ restic rewrite --new-host newhost --new-time "1999-01-01 11:11:11"
|
||||||
|
|
||||||
|
repository b7dbade3 opened (version 2, compression level auto)
|
||||||
|
[0:00] 100.00% 1 / 1 index files loaded
|
||||||
|
|
||||||
|
snapshot 8ed674f4 of [/path/to/abc.txt] at 2023-11-27 21:57:52.439139291 +0100 CET)
|
||||||
|
setting time to 1999-01-01 11:11:11 +0100 CET
|
||||||
|
setting host to newhost
|
||||||
|
saved new snapshot c05da643
|
||||||
|
|
||||||
|
modified 1 snapshots
|
||||||
|
|
||||||
|
|
||||||
.. _checking-integrity:
|
.. _checking-integrity:
|
||||||
|
|
||||||
Checking integrity and consistency
|
Checking integrity and consistency
|
||||||
|
@ -187,3 +187,22 @@ func ParseDurationOrPanic(s string) Duration {
|
|||||||
|
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadAllSnapshots returns a list of all snapshots in the repo.
|
||||||
|
// If a snapshot ID is in excludeIDs, it will not be included in the result.
|
||||||
|
func TestLoadAllSnapshots(ctx context.Context, repo Repository, excludeIDs IDSet) (snapshots Snapshots, err error) {
|
||||||
|
err = ForAllSnapshots(ctx, repo, repo, excludeIDs, func(id ID, sn *Snapshot, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots = append(snapshots, sn)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots, nil
|
||||||
|
}
|
||||||
|
@ -17,32 +17,13 @@ const (
|
|||||||
testDepth = 2
|
testDepth = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadAllSnapshots returns a list of all snapshots in the repo.
|
|
||||||
// If a snapshot ID is in excludeIDs, it will not be included in the result.
|
|
||||||
func loadAllSnapshots(ctx context.Context, repo restic.Repository, excludeIDs restic.IDSet) (snapshots restic.Snapshots, err error) {
|
|
||||||
err = restic.ForAllSnapshots(ctx, repo, repo, excludeIDs, func(id restic.ID, sn *restic.Snapshot, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots = append(snapshots, sn)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshots, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSnapshot(t *testing.T) {
|
func TestCreateSnapshot(t *testing.T) {
|
||||||
repo := repository.TestRepository(t)
|
repo := repository.TestRepository(t)
|
||||||
for i := 0; i < testCreateSnapshots; i++ {
|
for i := 0; i < testCreateSnapshots; i++ {
|
||||||
restic.TestCreateSnapshot(t, repo, testSnapshotTime.Add(time.Duration(i)*time.Second), testDepth)
|
restic.TestCreateSnapshot(t, repo, testSnapshotTime.Add(time.Duration(i)*time.Second), testDepth)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshots, err := loadAllSnapshots(context.TODO(), repo, restic.NewIDSet())
|
snapshots, err := restic.TestLoadAllSnapshots(context.TODO(), repo, restic.NewIDSet())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user