diff --git a/android-client/PMA.WebUI.pas b/android-client/PMA.WebUI.pas index 7345544..c3a3f03 100644 --- a/android-client/PMA.WebUI.pas +++ b/android-client/PMA.WebUI.pas @@ -149,6 +149,14 @@ type TDetailRow = record Caption, Value: string; Secret: Boolean; + /// Modifiable depuis le telephone. UN SEUL champ l'est pour l'instant - le + /// mot de passe - et c'est deliberé : creer ou supprimer une entree + /// demanderait des tombstones et la generation d'uuid, alors que changer un + /// mot de passe couvre l'essentiel du besoin sans rien de tout cela. + /// Marque ICI plutot que reconnu au libelle cote page : un intitule est de + /// la prose, et le jour ou il change la page cesserait silencieusement de + /// proposer la modification. + Editable: Boolean; end; function BuildRows(const E: TVaultEntry): TArray; @@ -157,13 +165,15 @@ var I: Integer; LMeta: string; - procedure Add(const ACaption, AValue: string; ASecret: Boolean); + procedure Add(const ACaption, AValue: string; ASecret: Boolean; + AEditable: Boolean = False); begin if AValue = '' then Exit; // une ligne vide n'apprend rien SetLength(LRows, Length(LRows) + 1); - LRows[High(LRows)].Caption := ACaption; - LRows[High(LRows)].Value := AValue; - LRows[High(LRows)].Secret := ASecret; + LRows[High(LRows)].Caption := ACaption; + LRows[High(LRows)].Value := AValue; + LRows[High(LRows)].Secret := ASecret; + LRows[High(LRows)].Editable := AEditable; end; begin @@ -171,8 +181,11 @@ begin Add('Username', E.Username, False); // Le corps d'une note est range dans le champ mot de passe : meme donnee, // autre intitule, et pas de raison de la masquer comme un secret. + // Seule la ligne du mot de passe est modifiable. Le corps d'une note le + // serait en principe, mais il se saisit sur plusieurs lignes - un autre + // controle, une autre fois. if E.IsNote then Add('Content', E.Password, False) - else Add('Password', E.Password, True); + else Add('Password', E.Password, True, True); for I := 0 to High(E.Fields) do Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret); @@ -220,6 +233,7 @@ begin LObj.AddPair('c', LRows[I].Caption); LObj.AddPair('v', LRows[I].Value); LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret)); + if LRows[I].Editable then LObj.AddPair('e', TJSONBool.Create(True)); LArr.AddElement(LObj); end; Result := LRoot.ToJSON; diff --git a/android-client/web/app.html b/android-client/web/app.html index f8c6956..44466ab 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -250,7 +250,7 @@
@@ -484,6 +484,9 @@ document.getElementById('qx').addEventListener('click', function () { // --- detail ---------------------------------------------------------------- var detailRows = []; +// Le dernier detail recu, pour redessiner la ligne apres une annulation sans +// avoir a le redemander a Delphi. +var lastDetail = null; function renderDetail(d) { detailRows = d.rows || []; @@ -503,6 +506,7 @@ function renderDetail(d) { + '
' + (r.s ? '' : '') + '' + + (r.e ? '' : '') + ''; } document.getElementById('fields').innerHTML = html; @@ -524,10 +528,43 @@ document.getElementById('fields').addEventListener('click', function (ev) { return; } var copy = t.getAttribute('data-copy'); - if (copy === 'totp') send('copy?f=totp'); - else if (copy === 'row') send('copy?f=row&k=' + t.getAttribute('data-k')); + if (copy === 'totp') { send('copy?f=totp'); return; } + if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; } + + var edit = t.getAttribute('data-edit'); + if (edit !== null) { openEditor(edit); return; } + if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; } + if (t.getAttribute('data-editsave') !== null) { + var input = document.getElementById('editval'); + if (!input || !input.value) return; // vide : on n'ecrase pas par megarde + send('setpwd?v=' + encodeURIComponent(input.value)); + return; + } }); +// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au +// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors +// null sans un mot - le bouton semblerait ne rien faire. +// +// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe +// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle +// une frappe qu'aucune confirmation ne rattrape. +function openEditor(k) { + var row = document.querySelector('[data-v="' + k + '"]'); + if (!row) return; + var line = row.parentNode; + line.innerHTML = + '' + + '' + + ''; + var input = document.getElementById('editval'); + input.value = detailRows[k].v; + input.focus(); + send('touch'); +} + function goList() { document.body.classList.remove('on-detail'); document.body.classList.add('on-list'); diff --git a/mainApk.pas b/mainApk.pas index d7b6a15..b551137 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -191,6 +191,7 @@ type procedure WriteTest; {$ENDIF} procedure OpenEntry(AVaultIndex: Integer); + procedure SaveEntryPassword(const ANewValue: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); procedure OnHardwareBack(Sender: TObject; var Key: Word; @@ -521,6 +522,120 @@ begin end; end; +/// Change le mot de passe de l'entree ouverte, et publie. +/// +/// TIRER, MODIFIER, RECHIFFRER, PUBLIER - dans cet ordre, et sur l'instantane +/// DU SERVEUR, pas sur le cache local. C'est ce qui remplace la fusion : ce +/// qu'on ne touche pas est deja la version la plus recente de tout le monde. +/// +/// Le seul conflit possible est qu'un autre appareil publie entre notre lecture +/// et notre ecriture. Gitea le refuse alors sur le sha (409), et on recommence +/// tout - y compris le dechiffrement, parce que le contenu a change. Trois +/// essais : au-dela, c'est que l'autre appareil publie en boucle, et insister +/// n'y changerait rien. +procedure TForm8.SaveEntryPassword(const ANewValue: string); +var + LUuid, LTitle: string; +begin + if FBusy then Exit; + if FCurrent.Uuid = '' then + begin + WebCall('PM.toast("This entry has no uuid - cannot be updated")'); + Exit; + end; + if FSyncPwd = '' then + begin + WebCall('PM.toast("Sync password missing - open the vault first")'); + Exit; + end; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + SetBusy(True); + + TThread.CreateAnonymousThread( + procedure + var + LClient: TSyncClient; + LGet, LPut: TSyncResult; + LPlain, LNew, LBody, LErr: string; + LAttempt: Integer; + LDone: Boolean; + begin + LClient := TSyncClient.Create(FUrl, FToken); + LDone := False; + LErr := ''; + try + for LAttempt := 1 to 3 do + begin + Log(Format('save: attempt %d - GET', [LAttempt])); + // Sans empreinte connue : on VEUT le corps, pas un 304. + LGet := LClient.Fetch; + if LGet.Status <> ssOK then + begin + LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); + Break; + end; + + try + LPlain := DecryptContainer(LGet.Container, FSyncPwd); + LNew := PatchEntryField(LPlain, LUuid, 'password', ANewValue); + except + on E: Exception do + begin + LErr := E.Message; + Break; + end; + end; + + LBody := EncryptContainer(LNew, FSyncPwd); + LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone'); + if LPut.Status = ssOK then + begin + Log('save: published, new sha ' + LPut.ETag); + // Le cache local suit le distant, avec SON empreinte : les laisser + // diverger ferait retelecharger et redechiffrer a la prochaine + // ouverture pour retrouver ce qu'on vient d'ecrire. + FETag := LPut.ETag; + try + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + LDone := True; + Break; + end; + + if LPut.Status = ssConflict then + begin + Log('save: 409 - another device published, re-reading'); + Continue; + end; + + LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]); + Break; + end; + + if (not LDone) and (LErr = '') then + LErr := 'the remote kept changing - try again'; + + TThread.Queue(nil, + procedure + begin + if LDone then + begin + AdoptVault(LNew); + WebCall('PM.toast(' + JsArg('Saved: ' + LTitle) + ')'); + end + else + WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); + end); + finally + LClient.Free; + TThread.Queue(nil, procedure begin SetBusy(False); end); + end; + end).Start; +end; + /// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre. /// /// Le premier PUT est le moment ou une erreur de transport se paie en donnees @@ -702,6 +817,12 @@ begin else if LAction = 'sync' then DoSync(nil) + + // La NOUVELLE valeur remonte par le pont - c'est la seule commande qui + // transporte un secret. Elle ne quitte pas le processus, l'iframe qui la + // porte est detruite, et HandleWebCommand ne journalise que l'action. + else if LAction = 'setpwd' then + SaveEntryPassword(TNetEncoding.URL.Decode(Param('v'))) {$IFDEF DEBUG} else if LAction = 'writetest' then WriteTest