diff --git a/app.go b/app.go index 686699b..57e9ed2 100644 --- a/app.go +++ b/app.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "fmt" "io/ioutil" @@ -10,7 +11,9 @@ import ( "restic-browser/backend/open" "restic-browser/backend/restic" + "github.com/pkg/errors" "github.com/wailsapp/wails/v2/pkg/runtime" + "golang.org/x/text/encoding/unicode" ) type ResticBrowserApp struct { @@ -104,9 +107,45 @@ func (r *ResticBrowserApp) OpenFileOrUrl(path string) error { return open.OpenFileOrURL(path) } +// read a text file, skipping BOM headers if present +func readTextFile(filename string) ([]byte, error) { + var ( + bomUTF8 = []byte{0xef, 0xbb, 0xbf} + bomUTF16BigEndian = []byte{0xfe, 0xff} + bomUTF16LittleEndian = []byte{0xff, 0xfe} + ) + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + if bytes.HasPrefix(data, bomUTF8) { + return data[len(bomUTF8):], nil + } + if !bytes.HasPrefix(data, bomUTF16BigEndian) && !bytes.HasPrefix(data, bomUTF16LittleEndian) { + return data, nil + } + // UseBom means automatic endianness selection + e := unicode.UTF16(unicode.BigEndian, unicode.UseBOM) + return e.NewDecoder().Bytes(data) +} + +// defaultRepoPassword determines the default restic repository password from the environment. +func defaultRepoPassword() (string, error) { + passwordFile := os.Getenv("RESTIC_PASSWORD_FILE") + if passwordFile != "" { + s, err := readTextFile(passwordFile) + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%s does not exist", passwordFile) + } + return strings.TrimSpace(strings.Replace(string(s), "\n", "", -1)), err + } + password := os.Getenv("RESTIC_PASSWORD") + return password, nil +} + func (r *ResticBrowserApp) DefaultRepoLocation() restic.Location { location := restic.Location{} - location.Password = os.Getenv("RESTIC_PASSWORD") + location.Password, _ = defaultRepoPassword() repo := os.Getenv("RESTIC_REPOSITORY") if len(repo) == 0 { return location @@ -140,6 +179,31 @@ func (r *ResticBrowserApp) DefaultRepoLocation() restic.Location { return location } +func (r *ResticBrowserApp) ReadPasswordFromFile() (string, error) { + options := runtime.OpenDialogOptions{ + DefaultDirectory: "", + DefaultFilename: "", + Title: "Please select a password file", + Filters: []runtime.FileFilter{}, + ShowHiddenFiles: true, + CanCreateDirectories: false, + ResolvesAliases: true, + TreatPackagesAsDirectories: true, + } + filename, err := runtime.OpenFileDialog(*r.context, options) + if err == nil && filename == "" { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to show dialog: %s", err.Error()) + } + bytes, err := readTextFile(filename) + if err != nil { + return "", fmt.Errorf("failed to read file: %s", err.Error()) + } + return strings.TrimSpace(strings.Replace(string(bytes), "\n", "", -1)), nil +} + func (r *ResticBrowserApp) SelectLocalRepo() (string, error) { options := runtime.OpenDialogOptions{ DefaultDirectory: "", diff --git a/frontend/src/components/location-dialog.ts b/frontend/src/components/location-dialog.ts index c8d558d..1f1afdc 100644 --- a/frontend/src/components/location-dialog.ts +++ b/frontend/src/components/location-dialog.ts @@ -6,7 +6,7 @@ import * as mobx from 'mobx'; import { appState } from '../states/app-state'; import { locationInfos, RepositoryType, Location } from '../states/location'; -import { SelectLocalRepo } from '../../wailsjs/go/main/ResticBrowserApp'; +import { SelectLocalRepo, ReadPasswordFromFile } from '../../wailsjs/go/main/ResticBrowserApp'; import { Notification } from '@vaadin/notification'; @@ -44,6 +44,7 @@ export class ResticBrowserLocationDialog extends MobxLitElement { this._location.setFromOtherLocation(appState.repoLocation); // bind this to callbacks this._browseLocalRepositoryPath = this._browseLocalRepositoryPath.bind(this); + this._readRepositoryPasswordFile = this._readRepositoryPasswordFile.bind(this); } private _browseLocalRepositoryPath() { @@ -61,6 +62,19 @@ export class ResticBrowserLocationDialog extends MobxLitElement { }); } + private _readRepositoryPasswordFile() { + ReadPasswordFromFile() + .then(mobx.action((password) => { + this._location.password = password; + })) + .catch((err) => { + Notification.show(`Invalid password file: '${err.message || err}'`, { + position: 'bottom-center', + theme: "error" + }); + }); + } + private _handleClose() { appState.repoLocation.setFromOtherLocation(this._location); this._handledClose = true; @@ -85,7 +99,7 @@ export class ResticBrowserLocationDialog extends MobxLitElement { })} > - `)} - { - this._location.password = (event.target as HTMLInputElement).value; - })} - > + + { + this._location.password = (event.target as HTMLInputElement).value; + })} + > + + Read + + `; diff --git a/frontend/wailsjs/go/main/ResticBrowserApp.d.ts b/frontend/wailsjs/go/main/ResticBrowserApp.d.ts index e111a70..1789ef3 100644 --- a/frontend/wailsjs/go/main/ResticBrowserApp.d.ts +++ b/frontend/wailsjs/go/main/ResticBrowserApp.d.ts @@ -14,6 +14,8 @@ export function OpenFileOrUrl(arg1:string):Promise; export function OpenRepo(arg1:restic.Location):Promise>; +export function ReadPasswordFromFile():Promise; + export function RestoreFile(arg1:string,arg2:restic.File):Promise; export function SelectLocalRepo():Promise; diff --git a/frontend/wailsjs/go/main/ResticBrowserApp.js b/frontend/wailsjs/go/main/ResticBrowserApp.js index 9dfd799..ebd462a 100644 --- a/frontend/wailsjs/go/main/ResticBrowserApp.js +++ b/frontend/wailsjs/go/main/ResticBrowserApp.js @@ -26,6 +26,10 @@ export function OpenRepo(arg1) { return window['go']['main']['ResticBrowserApp']['OpenRepo'](arg1); } +export function ReadPasswordFromFile() { + return window['go']['main']['ResticBrowserApp']['ReadPasswordFromFile'](); +} + export function RestoreFile(arg1, arg2) { return window['go']['main']['ResticBrowserApp']['RestoreFile'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 0b38845..55bfce1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,55 +1,5 @@ export namespace restic { - export class EnvValue { - name: string; - value: string; - - static createFrom(source: any = {}) { - return new EnvValue(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.name = source["name"]; - this.value = source["value"]; - } - } - export class Location { - prefix: string; - path: string; - credentials: EnvValue[]; - password: string; - - static createFrom(source: any = {}) { - return new Location(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.prefix = source["prefix"]; - this.path = source["path"]; - this.credentials = this.convertValues(source["credentials"], EnvValue); - this.password = source["password"]; - } - - convertValues(a: any, classs: any, asMap: boolean = false): any { - if (!a) { - return a; - } - if (a.slice) { - return (a as any[]).map(elem => this.convertValues(elem, classs)); - } else if ("object" === typeof a) { - if (asMap) { - for (const key of Object.keys(a)) { - a[key] = new classs(a[key]); - } - return a; - } - return new classs(a); - } - return a; - } - } export class File { name: string; type: string; @@ -104,6 +54,56 @@ export namespace restic { this.username = source["username"]; } } + export class EnvValue { + name: string; + value: string; + + static createFrom(source: any = {}) { + return new EnvValue(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.value = source["value"]; + } + } + export class Location { + prefix: string; + path: string; + credentials: EnvValue[]; + password: string; + + static createFrom(source: any = {}) { + return new Location(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.prefix = source["prefix"]; + this.path = source["path"]; + this.credentials = this.convertValues(source["credentials"], EnvValue); + this.password = source["password"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } diff --git a/go.mod b/go.mod index 63a0246..81d1307 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.17 require ( github.com/leaanthony/sail v0.3.0 + github.com/pkg/errors v0.9.1 github.com/wailsapp/wails/v2 v2.0.0-beta.42 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f + golang.org/x/text v0.3.7 ) require ( @@ -22,7 +24,6 @@ require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/tkrajina/go-reflector v0.5.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -30,5 +31,4 @@ require ( github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect golang.org/x/net v0.0.0-20220725212005-46097bf591d3 // indirect - golang.org/x/text v0.3.7 // indirect )