feature/ui - zatim ne uplne uhlazena ale celkem pouzitelna appka #1
12
fyne_ui.go
12
fyne_ui.go
@ -86,7 +86,7 @@ func NewUI() (stprageDir string, window fyne.Window) {
|
|||||||
|
|
||||||
// ShowPasswordVaultDialog zobrazí moderní odemykací / registrační dialog s countdown animací při špatném hesle.
|
// ShowPasswordVaultDialog zobrazí moderní odemykací / registrační dialog s countdown animací při špatném hesle.
|
||||||
// Callback je zavolán pouze při úspěchu (ne při neplatném hesle). Zrušení vrací password="".
|
// Callback je zavolán pouze při úspěchu (ne při neplatném hesle). Zrušení vrací password="".
|
||||||
func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(create bool, password string)) {
|
func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(create bool, password string, commonName string)) {
|
||||||
modeCreate := false
|
modeCreate := false
|
||||||
if _, err := os.Stat(vaultPath); err != nil {
|
if _, err := os.Stat(vaultPath); err != nil {
|
||||||
modeCreate = true
|
modeCreate = true
|
||||||
@ -104,6 +104,8 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
|
|||||||
createPw1.SetPlaceHolder("Heslo…")
|
createPw1.SetPlaceHolder("Heslo…")
|
||||||
createPw2 := widget.NewPasswordEntry()
|
createPw2 := widget.NewPasswordEntry()
|
||||||
createPw2.SetPlaceHolder("Znovu heslo…")
|
createPw2.SetPlaceHolder("Znovu heslo…")
|
||||||
|
commonNameEntry := widget.NewEntry()
|
||||||
|
commonNameEntry.SetPlaceHolder("Jméno identity (např. Alice)…")
|
||||||
strengthBar := widget.NewProgressBar()
|
strengthBar := widget.NewProgressBar()
|
||||||
strengthBar.Min = 0
|
strengthBar.Min = 0
|
||||||
strengthBar.Max = 100
|
strengthBar.Max = 100
|
||||||
@ -127,6 +129,7 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
|
|||||||
|
|
||||||
// Obě pole stejné šířky – žádné tlačítko uvnitř prvního řádku
|
// Obě pole stejné šířky – žádné tlačítko uvnitř prvního řádku
|
||||||
form := widget.NewForm(
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Jméno", commonNameEntry),
|
||||||
widget.NewFormItem("Heslo", createPw1),
|
widget.NewFormItem("Heslo", createPw1),
|
||||||
widget.NewFormItem("Potvrzení", createPw2),
|
widget.NewFormItem("Potvrzení", createPw2),
|
||||||
)
|
)
|
||||||
@ -208,7 +211,7 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
completed = true
|
completed = true
|
||||||
onResult(false, pw)
|
onResult(false, pw, "")
|
||||||
d.Hide()
|
d.Hide()
|
||||||
}
|
}
|
||||||
createBtn.OnTapped = func() {
|
createBtn.OnTapped = func() {
|
||||||
@ -224,7 +227,8 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
completed = true
|
completed = true
|
||||||
onResult(true, pw1)
|
cn := strings.TrimSpace(commonNameEntry.Text)
|
||||||
|
onResult(true, pw1, cn)
|
||||||
d.Hide()
|
d.Hide()
|
||||||
}
|
}
|
||||||
body := container.NewVBox(header, container.NewPadded(stack), statusLabel)
|
body := container.NewVBox(header, container.NewPadded(stack), statusLabel)
|
||||||
@ -234,7 +238,7 @@ func ShowPasswordVaultDialog(w fyne.Window, vaultPath string, onResult func(crea
|
|||||||
close(countdownCancel)
|
close(countdownCancel)
|
||||||
}
|
}
|
||||||
if !completed {
|
if !completed {
|
||||||
onResult(false, "")
|
onResult(false, "", "")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
d.Resize(fyne.NewSize(480, 320))
|
d.Resize(fyne.NewSize(480, 320))
|
||||||
|
|||||||
6
go.mod
6
go.mod
@ -4,7 +4,10 @@ go 1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.6.3
|
fyne.io/fyne/v2 v2.6.3
|
||||||
|
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
|
golang.org/x/crypto v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -28,18 +31,15 @@ require (
|
|||||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea // indirect
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rymdport/portal v0.4.1 // indirect
|
github.com/rymdport/portal v0.4.1 // indirect
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
github.com/stretchr/testify v1.10.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
golang.org/x/crypto v0.42.0 // indirect
|
|
||||||
golang.org/x/image v0.24.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -84,16 +84,10 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
|||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -100,7 +100,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateEncryptedStore vytvoří nový soubor; error pokud již existuje.
|
// CreateEncryptedStore vytvoří nový soubor; error pokud již existuje.
|
||||||
func CreateEncryptedStore(path, password string, generateIdentity bool) (SecureJSONStore, error) {
|
func CreateEncryptedStore(path, password string, generateIdentity bool, commonName string) (SecureJSONStore, error) {
|
||||||
if err := validatePasswordStrength(password); err != nil {
|
if err := validatePasswordStrength(password); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -116,7 +116,7 @@ func CreateEncryptedStore(path, password string, generateIdentity bool) (SecureJ
|
|||||||
kdfP: defaultKDFP,
|
kdfP: defaultKDFP,
|
||||||
plain: internalPlain{Updated: time.Now().UTC(), Data: make(map[string]json.RawMessage)},
|
plain: internalPlain{Updated: time.Now().UTC(), Data: make(map[string]json.RawMessage)},
|
||||||
}
|
}
|
||||||
if err := s.initNew(password, generateIdentity); err != nil {
|
if err := s.initNew(password, generateIdentity, commonName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.Flush(); err != nil {
|
if err := s.Flush(); err != nil {
|
||||||
@ -141,7 +141,7 @@ func OpenEncryptedStore(path, password string) (SecureJSONStore, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initNew vytvoří salt, odvodí klíč a vytvoří identitu pokud je třeba.
|
// initNew vytvoří salt, odvodí klíč a vytvoří identitu pokud je třeba.
|
||||||
func (s *secureJSONStore) initNew(password string, generateIdentity bool) error {
|
func (s *secureJSONStore) initNew(password string, generateIdentity bool, commonName string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
@ -155,14 +155,14 @@ func (s *secureJSONStore) initNew(password string, generateIdentity bool) error
|
|||||||
s.salt = salt
|
s.salt = salt
|
||||||
s.key = key
|
s.key = key
|
||||||
if generateIdentity {
|
if generateIdentity {
|
||||||
if err := s.generateIdentityLocked(); err != nil {
|
if err := s.generateIdentityLocked(commonName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *secureJSONStore) generateIdentityLocked() error {
|
func (s *secureJSONStore) generateIdentityLocked(commonName string) error {
|
||||||
// RSA 2048
|
// RSA 2048
|
||||||
pk, err := rsa.GenerateKey(rand.Reader, 2048)
|
pk, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -171,8 +171,11 @@ func (s *secureJSONStore) generateIdentityLocked() error {
|
|||||||
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(pk)})
|
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(pk)})
|
||||||
pubASN1, _ := x509.MarshalPKIXPublicKey(&pk.PublicKey)
|
pubASN1, _ := x509.MarshalPKIXPublicKey(&pk.PublicKey)
|
||||||
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubASN1})
|
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubASN1})
|
||||||
// Optional self-signed cert s rozumným CommonName (lze upravit později UI)
|
// Optional self-signed cert s CommonName uživatele (fallback pokud prázdný)
|
||||||
certPEM, _ := generateSelfSignedCert(pk, "Local User")
|
if strings.TrimSpace(commonName) == "" {
|
||||||
|
commonName = "Local User"
|
||||||
|
}
|
||||||
|
certPEM, _ := generateSelfSignedCert(pk, commonName)
|
||||||
_ = s.putLocked("_identity_private_pem", string(privPEM))
|
_ = s.putLocked("_identity_private_pem", string(privPEM))
|
||||||
_ = s.putLocked("_identity_public_pem", string(pubPEM))
|
_ = s.putLocked("_identity_public_pem", string(pubPEM))
|
||||||
_ = s.putLocked("_identity_cert_pem", string(certPEM))
|
_ = s.putLocked("_identity_cert_pem", string(certPEM))
|
||||||
|
|||||||
6
main.go
6
main.go
@ -47,7 +47,7 @@ func runFyne() {
|
|||||||
w.SetContent(placeholder)
|
w.SetContent(placeholder)
|
||||||
|
|
||||||
showDialog := func() {
|
showDialog := func() {
|
||||||
ShowPasswordVaultDialog(w, vaultPath, func(create bool, password string) {
|
ShowPasswordVaultDialog(w, vaultPath, func(create bool, password string, commonName string) {
|
||||||
if password == "" { // Cancel nebo zavření dialogu => ukonči app
|
if password == "" { // Cancel nebo zavření dialogu => ukonči app
|
||||||
fyne.CurrentApp().Quit()
|
fyne.CurrentApp().Quit()
|
||||||
return
|
return
|
||||||
@ -55,7 +55,7 @@ func runFyne() {
|
|||||||
var store encrypt.SecureJSONStore
|
var store encrypt.SecureJSONStore
|
||||||
var err error
|
var err error
|
||||||
if create {
|
if create {
|
||||||
store, err = encrypt.CreateEncryptedStore(vaultPath, password, true)
|
store, err = encrypt.CreateEncryptedStore(vaultPath, password, true, commonName)
|
||||||
} else {
|
} else {
|
||||||
store, err = encrypt.OpenEncryptedStore(vaultPath, password)
|
store, err = encrypt.OpenEncryptedStore(vaultPath, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -95,7 +95,7 @@ func RunWebApp() {
|
|||||||
}
|
}
|
||||||
var store encrypt.SecureJSONStore
|
var store encrypt.SecureJSONStore
|
||||||
if _, statErr := os.Stat(vaultPath); os.IsNotExist(statErr) {
|
if _, statErr := os.Stat(vaultPath); os.IsNotExist(statErr) {
|
||||||
s, err := encrypt.CreateEncryptedStore(vaultPath, pw, true)
|
s, err := encrypt.CreateEncryptedStore(vaultPath, pw, true, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
380
ui.go
380
ui.go
@ -36,6 +36,9 @@ type uiParts struct {
|
|||||||
showPeerQR bool
|
showPeerQR bool
|
||||||
payloadQR *canvas.Image
|
payloadQR *canvas.Image
|
||||||
showPayloadQR bool
|
showPayloadQR bool
|
||||||
|
peerContactID string // ID kontaktu vloženého do šifrování (pro přesnou identifikaci i při duplicitním CN)
|
||||||
|
// callback nastavený v buildEncryptTab pro aktualizaci hlavičky peeru z jiných tabů (kontakty)
|
||||||
|
updatePeerInfo func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildEntries() *uiParts {
|
func buildEntries() *uiParts {
|
||||||
@ -259,186 +262,7 @@ func copyClip(s string, parts *uiParts) {
|
|||||||
|
|
||||||
// assembleResponsive builds split view that collapses for narrow widths
|
// assembleResponsive builds split view that collapses for narrow widths
|
||||||
// Tab: Encrypt
|
// Tab: Encrypt
|
||||||
func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
// buildEncryptTab odstraněn – šifrování nyní pouze přes popup z listu kontaktů.
|
||||||
parts.cipherOut.Disable()
|
|
||||||
|
|
||||||
// Peer section with QR/Text toggle
|
|
||||||
peerContainer := container.NewVBox()
|
|
||||||
peerToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil)
|
|
||||||
var updatePeer func()
|
|
||||||
updatePeerQR := func(text string) {
|
|
||||||
if text == "" {
|
|
||||||
parts.peerQR.Image = nil
|
|
||||||
parts.peerQR.Refresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pngBytes, err := GenerateQRPNG(text, 512)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
img, err := LoadPNG(pngBytes)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts.peerQR.Image = img
|
|
||||||
parts.peerQR.Refresh()
|
|
||||||
}
|
|
||||||
updatePeer = func() {
|
|
||||||
peerContainer.Objects = nil
|
|
||||||
if parts.showPeerQR {
|
|
||||||
updatePeerQR(parts.peer.Text)
|
|
||||||
peerContainer.Add(parts.peerQR) // copy tlačítko jen v toolbaru
|
|
||||||
peerToggleAction.SetIcon(theme.VisibilityOffIcon())
|
|
||||||
} else {
|
|
||||||
peerContainer.Add(parts.peer)
|
|
||||||
peerToggleAction.SetIcon(theme.VisibilityIcon())
|
|
||||||
}
|
|
||||||
peerContainer.Refresh()
|
|
||||||
}
|
|
||||||
peerToggleAction.OnActivated = func() { parts.showPeerQR = !parts.showPeerQR; updatePeer() }
|
|
||||||
parts.peer.OnChanged = func(string) {
|
|
||||||
if parts.showPeerQR {
|
|
||||||
updatePeerQR(parts.peer.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePeer()
|
|
||||||
|
|
||||||
// Output section toggle (QR vs text)
|
|
||||||
outputContainer := container.NewVBox()
|
|
||||||
outputToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil)
|
|
||||||
updateQR := func(text string) {
|
|
||||||
if text == "" {
|
|
||||||
parts.cipherQR.Image = nil
|
|
||||||
parts.cipherQR.Refresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pngBytes, err := GenerateQRPNG(text, 512)
|
|
||||||
if err != nil {
|
|
||||||
parts.showToast("QR error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
img, err := LoadPNG(pngBytes)
|
|
||||||
if err != nil {
|
|
||||||
parts.showToast("PNG err")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts.cipherQR.Image = img
|
|
||||||
parts.cipherQR.Refresh()
|
|
||||||
}
|
|
||||||
updateOutput := func() {
|
|
||||||
outputContainer.Objects = nil
|
|
||||||
if parts.showQR { // show QR mode
|
|
||||||
updateQR(parts.cipherOut.Text)
|
|
||||||
outputContainer.Add(parts.cipherQR)
|
|
||||||
outputToggleAction.SetIcon(theme.VisibilityOffIcon())
|
|
||||||
} else {
|
|
||||||
outputContainer.Add(parts.cipherOut)
|
|
||||||
outputToggleAction.SetIcon(theme.VisibilityIcon())
|
|
||||||
}
|
|
||||||
outputContainer.Refresh()
|
|
||||||
}
|
|
||||||
outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateOutput() }
|
|
||||||
updateOutput()
|
|
||||||
|
|
||||||
encAction := func() {
|
|
||||||
m := parts.msg.Text
|
|
||||||
p := parts.peer.Text
|
|
||||||
if m == "" || p == "" {
|
|
||||||
parts.showToast("Chybí data")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func(msg, peer string) {
|
|
||||||
res, err := svc.Encrypt(msg, peer)
|
|
||||||
if err != nil {
|
|
||||||
fyne.Do(func() { parts.cipherOut.SetText(""); updateQR(""); parts.showToast("Chyba") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fyne.Do(func() {
|
|
||||||
parts.cipherOut.SetText(res)
|
|
||||||
// refresh whichever mode is active
|
|
||||||
updateOutput()
|
|
||||||
parts.showToast("OK")
|
|
||||||
})
|
|
||||||
}(m, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
importPeerQR := func() {
|
|
||||||
fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
|
|
||||||
if err != nil || r == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
data, _ := io.ReadAll(r)
|
|
||||||
img, err := LoadPNG(data)
|
|
||||||
if err != nil {
|
|
||||||
dialog.NewError(err, fyne.CurrentApp().Driver().AllWindows()[0]).Show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text, err := DecodeQR(img)
|
|
||||||
if err != nil {
|
|
||||||
dialog.NewError(err, fyne.CurrentApp().Driver().AllWindows()[0]).Show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts.peer.SetText(text)
|
|
||||||
}, fyne.CurrentApp().Driver().AllWindows()[0])
|
|
||||||
fd.SetFilter(storage.NewExtensionFileFilter([]string{".png"}))
|
|
||||||
fd.Show()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toolbars
|
|
||||||
peerToolbar := widget.NewToolbar(
|
|
||||||
widget.NewToolbarAction(theme.ContentPasteIcon(), func() { parts.peer.SetText(fyne.CurrentApp().Clipboard().Content()) }),
|
|
||||||
widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.peer.Text, parts) }),
|
|
||||||
widget.NewToolbarAction(theme.FolderOpenIcon(), importPeerQR),
|
|
||||||
widget.NewToolbarAction(theme.ContentClearIcon(), func() { parts.peer.SetText("") }),
|
|
||||||
widget.NewToolbarSeparator(),
|
|
||||||
peerToggleAction,
|
|
||||||
)
|
|
||||||
outputToolbar := widget.NewToolbar(
|
|
||||||
widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.cipherOut.Text, parts) }),
|
|
||||||
widget.NewToolbarAction(theme.ContentClearIcon(), func() {
|
|
||||||
parts.cipherOut.SetText("")
|
|
||||||
parts.msg.SetText("")
|
|
||||||
if parts.showQR {
|
|
||||||
updateQR("")
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
widget.NewToolbarSeparator(),
|
|
||||||
outputToggleAction,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Šifrování automaticky při změně zprávy nebo peer
|
|
||||||
parts.msg.OnChanged = func(string) { encAction() }
|
|
||||||
parts.peer.OnChanged = func(string) {
|
|
||||||
if parts.showPeerQR {
|
|
||||||
updatePeerQR(parts.peer.Text)
|
|
||||||
}
|
|
||||||
encAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sections
|
|
||||||
peerSection := container.NewVBox(
|
|
||||||
container.NewHBox(widget.NewLabel("Veřejný klíč příjemce"), layout.NewSpacer(), peerToolbar),
|
|
||||||
peerContainer,
|
|
||||||
)
|
|
||||||
msgSection := container.NewVBox(
|
|
||||||
widget.NewLabel("Zpráva"),
|
|
||||||
parts.msg,
|
|
||||||
)
|
|
||||||
outputSection := container.NewVBox(
|
|
||||||
container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar),
|
|
||||||
outputContainer,
|
|
||||||
)
|
|
||||||
// Split: message (top) vs output (bottom) for better prostor "do spodu"
|
|
||||||
split := container.NewVSplit(msgSection, outputSection)
|
|
||||||
split.SetOffset(0.40)
|
|
||||||
group := container.NewVBox(
|
|
||||||
widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
||||||
peerSection,
|
|
||||||
split,
|
|
||||||
)
|
|
||||||
return group
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab: Decrypt
|
// Tab: Decrypt
|
||||||
func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
||||||
@ -558,9 +382,9 @@ func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject {
|
func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject {
|
||||||
idTab := container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath))
|
idTab := container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath))
|
||||||
contactsTab := container.NewTabItem("Kontakty", buildContactsTab(parts, svc))
|
contactsTab := container.NewTabItem("Kontakty", buildContactsTab(parts, svc))
|
||||||
encTab := container.NewTabItem("Šifrování", buildEncryptTab(parts, svc))
|
// Odebrána samostatná záložka Šifrování – nahrazena popupem z listu kontaktů.
|
||||||
decTab := container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc))
|
decTab := container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc))
|
||||||
tabs := container.NewAppTabs(idTab, contactsTab, encTab, decTab)
|
tabs := container.NewAppTabs(idTab, contactsTab, decTab)
|
||||||
tabs.SetTabLocation(container.TabLocationTop)
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
// apply theme; allow toggle via hidden shortcut Ctrl+T (demo)
|
// apply theme; allow toggle via hidden shortcut Ctrl+T (demo)
|
||||||
@ -626,7 +450,95 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
filtered = tmp
|
filtered = tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left list (includes optional draft as first row)
|
// Inline popup builder for encryption
|
||||||
|
openEncryptPopup := func(ct Contact) {
|
||||||
|
// Minimalist popup: jméno kontaktu, zpráva, tlačítko Šifrovat, pouze QR kód výsledku
|
||||||
|
msgEntry := widget.NewMultiLineEntry()
|
||||||
|
msgEntry.SetPlaceHolder("Zpráva…")
|
||||||
|
msgEntry.SetMinRowsVisible(6)
|
||||||
|
qrImg := canvas.NewImageFromImage(nil)
|
||||||
|
qrImg.FillMode = canvas.ImageFillContain
|
||||||
|
qrImg.SetMinSize(fyne.NewSize(260, 260))
|
||||||
|
status := widget.NewLabel("")
|
||||||
|
status.Wrapping = fyne.TextWrapWord
|
||||||
|
updateQR := func(text string) {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
qrImg.Image = nil
|
||||||
|
qrImg.Refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b, err := GenerateQRPNG(text, 512); err == nil {
|
||||||
|
if im, err2 := LoadPNG(b); err2 == nil {
|
||||||
|
qrImg.Image = im
|
||||||
|
qrImg.Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var win fyne.Window = fyne.CurrentApp().Driver().AllWindows()[0]
|
||||||
|
lastCipher := ""
|
||||||
|
doEncrypt := func() {
|
||||||
|
m := strings.TrimSpace(msgEntry.Text)
|
||||||
|
fyne.Do(func() {
|
||||||
|
if m == "" {
|
||||||
|
status.SetText("Zpráva je prázdná")
|
||||||
|
lastCipher = ""
|
||||||
|
updateQR("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status.SetText("Šifruji…")
|
||||||
|
})
|
||||||
|
if m == "" { // už jsme UI vyřešili
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func(txt string) {
|
||||||
|
res, err := svc.Encrypt(txt, ct.Cert)
|
||||||
|
if err != nil {
|
||||||
|
fyne.Do(func() { status.SetText("Chyba: " + err.Error()) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fyne.Do(func() {
|
||||||
|
lastCipher = res
|
||||||
|
updateQR(res)
|
||||||
|
status.SetText("Hotovo")
|
||||||
|
})
|
||||||
|
}(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
var autoTimer *time.Timer
|
||||||
|
msgEntry.OnChanged = func(string) {
|
||||||
|
if autoTimer != nil {
|
||||||
|
autoTimer.Stop()
|
||||||
|
}
|
||||||
|
autoTimer = time.AfterFunc(300*time.Millisecond, doEncrypt)
|
||||||
|
}
|
||||||
|
copyBtn := widget.NewButton("Kopírovat payload", func() {
|
||||||
|
if lastCipher != "" {
|
||||||
|
copyClip(lastCipher, parts)
|
||||||
|
status.SetText("Zkopírováno")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
popupContent := container.NewVBox(
|
||||||
|
widget.NewLabel("Zpráva"),
|
||||||
|
msgEntry,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(widget.NewLabel("QR kód"), layout.NewSpacer(), copyBtn),
|
||||||
|
qrImg,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
cn := extractCN(ct.Cert)
|
||||||
|
titleName := ct.Name
|
||||||
|
if titleName == "" {
|
||||||
|
titleName = "(bez názvu)"
|
||||||
|
}
|
||||||
|
if cn != "" && !strings.Contains(titleName, cn) {
|
||||||
|
titleName = fmt.Sprintf("%s (%s)", titleName, cn)
|
||||||
|
}
|
||||||
|
popup := dialog.NewCustom(fmt.Sprintf("Poslat zprávu: %s", titleName), "Zavřít", popupContent, win)
|
||||||
|
popup.Resize(fyne.NewSize(640, 520))
|
||||||
|
popup.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left list (includes optional draft as first row) — custom row with label + button
|
||||||
list := widget.NewList(
|
list := widget.NewList(
|
||||||
func() int {
|
func() int {
|
||||||
if draft != nil {
|
if draft != nil {
|
||||||
@ -634,35 +546,65 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
}
|
}
|
||||||
return len(filtered)
|
return len(filtered)
|
||||||
},
|
},
|
||||||
func() fyne.CanvasObject { return widget.NewLabel("") },
|
func() fyne.CanvasObject {
|
||||||
|
lbl := widget.NewLabel("")
|
||||||
|
btn := widget.NewButton("Poslat zprávu", nil)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
row := container.NewBorder(nil, nil, nil, btn, lbl)
|
||||||
|
return row
|
||||||
|
},
|
||||||
func(i widget.ListItemID, o fyne.CanvasObject) {
|
func(i widget.ListItemID, o fyne.CanvasObject) {
|
||||||
|
row := o.(*fyne.Container)
|
||||||
|
lbl := row.Objects[0].(*widget.Label)
|
||||||
|
btn := row.Objects[1].(*widget.Button)
|
||||||
if draft != nil {
|
if draft != nil {
|
||||||
if int(i) == 0 { // draft row
|
if int(i) == 0 {
|
||||||
name := draft.Name
|
name := draft.Name
|
||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
name = "(nový)"
|
name = "(nový)"
|
||||||
}
|
}
|
||||||
o.(*widget.Label).SetText("✳ " + name + " (nový)")
|
cn := extractCN(draft.Cert)
|
||||||
|
if cn != "" {
|
||||||
|
lbl.SetText(fmt.Sprintf("✳ %s (nový) (%s)", name, cn))
|
||||||
|
} else {
|
||||||
|
lbl.SetText("✳ " + name + " (nový)")
|
||||||
|
}
|
||||||
|
btn.Disable()
|
||||||
|
btn.OnTapped = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// shift index for real contacts
|
|
||||||
real := int(i) - 1
|
real := int(i) - 1
|
||||||
if real >= 0 && real < len(filtered) {
|
if real >= 0 && real < len(filtered) {
|
||||||
name := filtered[real].Name
|
c := filtered[real]
|
||||||
|
name := c.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "(bez názvu)"
|
name = "(bez názvu)"
|
||||||
}
|
}
|
||||||
o.(*widget.Label).SetText(name)
|
cn := extractCN(c.Cert)
|
||||||
|
if cn != "" {
|
||||||
|
lbl.SetText(fmt.Sprintf("%s (%s)", name, cn))
|
||||||
|
} else {
|
||||||
|
lbl.SetText(name)
|
||||||
|
}
|
||||||
|
btn.Enable()
|
||||||
|
btn.OnTapped = func() { openEncryptPopup(c) }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// no draft
|
|
||||||
if i >= 0 && int(i) < len(filtered) {
|
if i >= 0 && int(i) < len(filtered) {
|
||||||
name := filtered[i].Name
|
c := filtered[i]
|
||||||
|
name := c.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "(bez názvu)"
|
name = "(bez názvu)"
|
||||||
}
|
}
|
||||||
o.(*widget.Label).SetText(name)
|
cn := extractCN(c.Cert)
|
||||||
|
if cn != "" {
|
||||||
|
lbl.SetText(fmt.Sprintf("%s (%s)", name, cn))
|
||||||
|
} else {
|
||||||
|
lbl.SetText(name)
|
||||||
|
}
|
||||||
|
btn.Enable()
|
||||||
|
btn.OnTapped = func() { openEncryptPopup(c) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -691,6 +633,7 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
certEntry := widget.NewMultiLineEntry()
|
certEntry := widget.NewMultiLineEntry()
|
||||||
certEntry.SetMinRowsVisible(8)
|
certEntry.SetMinRowsVisible(8)
|
||||||
certEntry.Wrapping = fyne.TextWrapWord
|
certEntry.Wrapping = fyne.TextWrapWord
|
||||||
|
// (Původní okamžité dotazování na CN při vložení zrušeno – nyní se ptáme až při ukládání)
|
||||||
// Detail toolbar
|
// Detail toolbar
|
||||||
pasteAction := widget.NewToolbarAction(theme.ContentPasteIcon(), func() {
|
pasteAction := widget.NewToolbarAction(theme.ContentPasteIcon(), func() {
|
||||||
clip := fyne.CurrentApp().Clipboard().Content()
|
clip := fyne.CurrentApp().Clipboard().Content()
|
||||||
@ -742,21 +685,25 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
parts.showToast("Chybí cert")
|
parts.showToast("Chybí cert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if name == "" || name == "Kontakt" || name == "Nový kontakt" {
|
cn := extractCN(cert)
|
||||||
name = makeDefaultName()
|
// always ask if CN exists and (a) name empty/reserved OR (b) name differs from CN
|
||||||
|
shouldAsk := cn != "" && (name == "" || name == "Kontakt" || name == "Nový kontakt" || name != cn)
|
||||||
|
proceed := func(finalName string) {
|
||||||
|
if finalName == "" || finalName == "Kontakt" || finalName == "Nový kontakt" {
|
||||||
|
finalName = makeDefaultName()
|
||||||
}
|
}
|
||||||
if draft != nil { // saving draft as new contact
|
if draft != nil { // saving draft as new contact
|
||||||
_ = svc.SaveContact(Contact{Name: name, Cert: cert})
|
_ = svc.SaveContact(Contact{Name: finalName, Cert: cert})
|
||||||
draft = nil
|
draft = nil
|
||||||
searchQuery = "" // clear filter to show new
|
searchQuery = "" // clear filter to show new
|
||||||
search.SetText("")
|
search.SetText("")
|
||||||
} else if selected < 0 || selected >= len(filtered) { // new without draft (fallback)
|
} else if selected < 0 || selected >= len(filtered) { // new without draft (fallback)
|
||||||
_ = svc.SaveContact(Contact{Name: name, Cert: cert})
|
_ = svc.SaveContact(Contact{Name: finalName, Cert: cert})
|
||||||
searchQuery = ""
|
searchQuery = ""
|
||||||
search.SetText("")
|
search.SetText("")
|
||||||
} else { // update existing
|
} else { // update existing
|
||||||
c := filtered[selected]
|
c := filtered[selected]
|
||||||
c.Name = name
|
c.Name = finalName
|
||||||
c.Cert = cert
|
c.Cert = cert
|
||||||
_ = svc.SaveContact(c)
|
_ = svc.SaveContact(c)
|
||||||
}
|
}
|
||||||
@ -765,9 +712,40 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
list.Refresh()
|
list.Refresh()
|
||||||
updateEmptyState()
|
updateEmptyState()
|
||||||
parts.showToast("Uloženo")
|
parts.showToast("Uloženo")
|
||||||
|
}
|
||||||
|
if shouldAsk {
|
||||||
|
// build message depending on situation
|
||||||
|
msg := fmt.Sprintf("Certifikát obsahuje Common Name:\n\n%s\n\nPoužít ho jako název kontaktu? (Aktuálně: %q)", cn, name)
|
||||||
|
dialog.NewCustomConfirm("Common Name", "Použít", "Ponechat", widget.NewLabel(msg), func(ok bool) {
|
||||||
|
if ok {
|
||||||
|
proceed(cn)
|
||||||
|
} else {
|
||||||
|
proceed(name)
|
||||||
|
}
|
||||||
|
}, fyne.CurrentApp().Driver().AllWindows()[0]).Show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proceed(name)
|
||||||
})
|
})
|
||||||
useBtn := widget.NewButtonWithIcon("Použít ve Šifrování", theme.ConfirmIcon(), func() {
|
useBtn := widget.NewButtonWithIcon("Použít ve Šifrování", theme.ConfirmIcon(), func() {
|
||||||
|
// Určit aktuální kontakt (draft nebo vybraný reálný)
|
||||||
|
var currentID string
|
||||||
|
if draft != nil && selected == -1 {
|
||||||
|
currentID = draft.ID
|
||||||
|
} else if selected >= 0 && selected < len(filtered) {
|
||||||
|
currentID = filtered[selected].ID
|
||||||
|
}
|
||||||
|
// Vždy vynutit změnu: smazat a hned nastavit
|
||||||
|
parts.peer.SetText("")
|
||||||
|
parts.peerContactID = "" // reset aby updatePeerInfo neukazoval staré jméno
|
||||||
|
fyne.Do(func() {
|
||||||
parts.peer.SetText(certEntry.Text)
|
parts.peer.SetText(certEntry.Text)
|
||||||
|
parts.peerContactID = currentID
|
||||||
|
// Force refresh hlavičky peeru i v případě, že se formát certu liší (trim apod.)
|
||||||
|
if parts.updatePeerInfo != nil {
|
||||||
|
parts.updatePeerInfo()
|
||||||
|
}
|
||||||
|
})
|
||||||
parts.showToast("Kontakt vložen do Šifrování")
|
parts.showToast("Kontakt vložen do Šifrování")
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -862,12 +840,10 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
|
|||||||
defer r.Close()
|
defer r.Close()
|
||||||
b, _ := io.ReadAll(r)
|
b, _ := io.ReadAll(r)
|
||||||
txt := string(b)
|
txt := string(b)
|
||||||
nm := extractCN(txt)
|
certEntry.SetText(txt) // OnChanged will offer CN usage if present
|
||||||
if nm == "" {
|
if extractCN(txt) == "" && strings.TrimSpace(nameEntry.Text) == "" {
|
||||||
nm = "Nový kontakt"
|
nameEntry.SetText("Nový kontakt")
|
||||||
}
|
}
|
||||||
nameEntry.SetText(nm)
|
|
||||||
certEntry.SetText(txt)
|
|
||||||
selected = -1
|
selected = -1
|
||||||
}, fyne.CurrentApp().Driver().AllWindows()[0])
|
}, fyne.CurrentApp().Driver().AllWindows()[0])
|
||||||
fd.Show()
|
fd.Show()
|
||||||
|
|||||||
@ -166,15 +166,21 @@ func generateUniqueContactID(existing []Contact) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractCN(pemText string) string {
|
func extractCN(pemText string) string {
|
||||||
block, _ := pem.Decode([]byte(pemText))
|
// Support multiple concatenated PEM blocks (public key + cert, or chain)
|
||||||
|
rest := []byte(pemText)
|
||||||
|
for len(rest) > 0 {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
if block == nil {
|
if block == nil {
|
||||||
return ""
|
break
|
||||||
}
|
}
|
||||||
if block.Type == "CERTIFICATE" {
|
if block.Type == "CERTIFICATE" {
|
||||||
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
|
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
|
||||||
return cert.Subject.CommonName
|
return cert.Subject.CommonName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// continue to next block until certificate found
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user