feat(android): entries can be created on the phone
A vault you cannot add to is not a vault, so an account created here was useless without this. A plus button next to the padlock, a form, and a kind - login or note, which the vault distinguishes and whose body is the password field either way. One save path now, two transports, and the rule that decides between them is: if a local container exists it is the authority. A vault born here has no repository; a vault pulled from one has no local container. The case where both exist - a local vault that later gains a repository - needs a merge, and that is section 7 step 4. The patch arrives as a function rather than an already-patched snapshot: the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite whatever another device published in between. That constraint is why the two transports could not simply share a finished payload. Two traps closed while passing: the form left the typed password sitting in the document after the save, and a refusal left its Save button dead with the text behind it. Verified on the device that the button and the form appear and read correctly. Saving was not exercised - the vault on this phone syncs to a real repository, and a test entry would have been published to it with no way to delete it from here yet.
This commit is contained in:
@@ -40,6 +40,17 @@ function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): strin
|
||||
/// naisse pas dans un dialecte a part.
|
||||
function NewEmptySnapshot(const AUsername: string): string;
|
||||
|
||||
/// Ajoute une entree a l'instantane et rend le JSON complet.
|
||||
///
|
||||
/// LA MEME FORME QUE CE QU'ECRIT LE POSTE, cles vides comprises : une entree
|
||||
/// nee ici sera relue la-bas, et une cle absente ne se comporte pas toujours
|
||||
/// comme une cle vide. `folder` vaut 'All' parce que c'est le dossier par
|
||||
/// defaut du poste, pas parce que c'est joli.
|
||||
///
|
||||
/// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore.
|
||||
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
|
||||
AKind: string): string;
|
||||
|
||||
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
|
||||
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
|
||||
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
|
||||
@@ -75,6 +86,50 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
|
||||
AKind: string): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
LEntries: TJSONArray;
|
||||
LNow: string;
|
||||
begin
|
||||
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||
if LRoot = nil then
|
||||
raise EPMAPatch.Create('Snapshot is not a JSON object');
|
||||
try
|
||||
LEntries := LRoot.GetValue('entries') as TJSONArray;
|
||||
if LEntries = nil then
|
||||
raise EPMAPatch.Create('Snapshot has no entries array');
|
||||
|
||||
LNow := NowUtcIso;
|
||||
LEntry := TJSONObject.Create;
|
||||
// Minuscules et sans accolades : la forme que tout le reste emploie.
|
||||
LEntry.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
|
||||
LEntry.AddPair('title', ATitle);
|
||||
LEntry.AddPair('site', ASite);
|
||||
LEntry.AddPair('username', AUsername);
|
||||
// Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de
|
||||
// colonne separee, ni ici ni sur le poste.
|
||||
LEntry.AddPair('password', APassword);
|
||||
LEntry.AddPair('folder', 'All');
|
||||
LEntry.AddPair('tags', TJSONArray.Create);
|
||||
LEntry.AddPair('totp_secret', '');
|
||||
LEntry.AddPair('kind', AKind);
|
||||
LEntry.AddPair('template', '');
|
||||
LEntry.AddPair('custom_fields', TJSONArray.Create);
|
||||
LEntry.AddPair('attachments', TJSONArray.Create);
|
||||
LEntry.AddPair('icon_b64', '');
|
||||
LEntry.AddPair('icon_custom', TJSONNumber.Create(0));
|
||||
LEntry.AddPair('created_at', LNow);
|
||||
LEntry.AddPair('updated_at', LNow);
|
||||
LEntries.AddElement(LEntry);
|
||||
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
|
||||
var
|
||||
LRoot, LEntry: TJSONObject;
|
||||
|
||||
@@ -323,6 +323,12 @@
|
||||
autocapitalize="none" spellcheck="false">
|
||||
<div class="clear" id="qx">×</div>
|
||||
</div>
|
||||
<button class="ico" id="new" aria-label="New entry">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ico" id="lock" aria-label="Lock vault">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round">
|
||||
@@ -689,6 +695,80 @@ addEventListener('keydown', function (ev) {
|
||||
if (save && !save.disabled) save.click();
|
||||
});
|
||||
|
||||
// NOUVELLE ENTREE - dans l'ecran de DETAIL, pas dans une quatrieme vue.
|
||||
//
|
||||
// Le rail en porte trois, et chacune coute ses regles de transformation. Le
|
||||
// detail a deja tout ce qu'il faut : un titre, un retour, une colonne qui
|
||||
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
|
||||
// place a l'ecran, et c'est exactement ce que cette vue veut dire.
|
||||
var newKind = 'login';
|
||||
|
||||
function newField(id, label, type) {
|
||||
return '<div class="field"><div class="cap">' + label + '</div>'
|
||||
+ '<div class="line"><input class="field-in" id="' + id + '" type="'
|
||||
+ type + '" autocomplete="off" autocorrect="off" autocapitalize="none"'
|
||||
+ ' spellcheck="false" style="margin:0;flex:1;min-width:0;width:auto">'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function openNewEntry() {
|
||||
newKind = 'login';
|
||||
detailRows = [];
|
||||
document.getElementById('dtitle').textContent = 'New entry';
|
||||
document.getElementById('fields').innerHTML =
|
||||
'<div class="field"><div class="cap">Kind</div><div class="line">'
|
||||
+ '<button class="btn main" data-kind="login">Login</button>'
|
||||
+ '<button class="btn" data-kind="note">Note</button></div></div>'
|
||||
+ newField('nTitle', 'Name', 'text')
|
||||
+ newField('nSite', 'Site', 'text')
|
||||
+ newField('nUser', 'Username', 'text')
|
||||
// EN CLAIR pendant la saisie, comme l'editeur : on tape un mot de passe
|
||||
// qu'aucune confirmation ne rattrape et qu'on ne peut relire nulle part.
|
||||
+ newField('nPwd', 'Password', 'text')
|
||||
+ '<div class="field"><div class="line">'
|
||||
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
|
||||
// pleine vient d'etre etire dans son conteneur.
|
||||
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
|
||||
+ '</div></div>';
|
||||
document.body.classList.remove('on-list');
|
||||
document.body.classList.add('on-detail');
|
||||
view = 'detail';
|
||||
send('state?view=detail');
|
||||
}
|
||||
|
||||
// Un seul ecouteur pour le formulaire : ses boutons naissent et meurent avec
|
||||
// lui, et en attacher a chaque ouverture les empilerait.
|
||||
document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
var kind = ev.target.getAttribute('data-kind');
|
||||
if (kind) {
|
||||
newKind = kind;
|
||||
var all = document.querySelectorAll('[data-kind]');
|
||||
for (var i = 0; i < all.length; i++)
|
||||
all[i].classList.toggle('main', all[i].getAttribute('data-kind') === kind);
|
||||
// Le corps d'une note EST son mot de passe cote stockage : meme champ,
|
||||
// autre intitule, et rien a masquer.
|
||||
var caps = document.querySelectorAll('#fields .cap');
|
||||
for (i = 0; i < caps.length; i++) {
|
||||
if (caps[i].textContent === 'Password' || caps[i].textContent === 'Content')
|
||||
caps[i].textContent = (kind === 'note') ? 'Content' : 'Password';
|
||||
// Une note n'a ni site ni identifiant. Les laisser proposes revient a
|
||||
// demander de remplir ce qui ne sera jamais affiche.
|
||||
if (caps[i].textContent === 'Site' || caps[i].textContent === 'Username')
|
||||
caps[i].parentNode.hidden = (kind === 'note');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.target.id !== 'nSave') return;
|
||||
var v = function (id) { return document.getElementById(id).value.trim(); };
|
||||
ev.target.disabled = true;
|
||||
send('newentry?t=' + encodeURIComponent(v('nTitle'))
|
||||
+ '&s=' + encodeURIComponent(v('nSite'))
|
||||
+ '&u=' + encodeURIComponent(v('nUser'))
|
||||
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
|
||||
+ '&k=' + newKind);
|
||||
});
|
||||
document.getElementById('new').addEventListener('click', openNewEntry);
|
||||
|
||||
function goList() {
|
||||
document.body.classList.remove('on-detail');
|
||||
document.body.classList.add('on-list');
|
||||
@@ -710,6 +790,11 @@ var PM = {
|
||||
q.value = ''; query = '';
|
||||
document.body.classList.remove('searching', 'on-detail');
|
||||
document.body.classList.add('on-list');
|
||||
// LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le
|
||||
// mot de passe qu'on vient de taper ; le laisser dans le document apres
|
||||
// l'enregistrement le laisserait la jusqu'au verrouillage.
|
||||
detailRows = [];
|
||||
document.getElementById('fields').innerHTML = '';
|
||||
view = 'list';
|
||||
renderFolders();
|
||||
renderRows();
|
||||
@@ -719,6 +804,11 @@ var PM = {
|
||||
/// immediats : l'editeur y restait ouvert, boutons desactives, et Cancel
|
||||
/// lui-meme ne repondait plus.
|
||||
editDone: function () {
|
||||
// Le formulaire de creation passe par ici aussi : son bouton se desactive
|
||||
// a l'envoi, et sans cela un refus le laissait mort avec le texte saisi
|
||||
// derriere - a retaper sans savoir pourquoi.
|
||||
var save = document.getElementById('nSave');
|
||||
if (save) { save.disabled = false; return; }
|
||||
if (lastDetail) renderDetail(lastDetail);
|
||||
},
|
||||
|
||||
|
||||
+108
-29
@@ -204,7 +204,12 @@ type
|
||||
procedure PatchSelfCheck;
|
||||
{$ENDIF}
|
||||
procedure OpenEntry(AVaultIndex: Integer);
|
||||
procedure CommitChange(const ALabel, AComment, AReopenUuid: string;
|
||||
const APatch: TFunc<string, string>);
|
||||
procedure SaveEntryPassword(const ANewValue: string);
|
||||
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
|
||||
/// comme 'setpwd' - meme regle, meme journalisation muette.
|
||||
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
|
||||
procedure ReopenEntryByUuid(const AUuid: string);
|
||||
procedure OnClipTimer(Sender: TObject);
|
||||
procedure OnDeadline(Sender: TObject);
|
||||
@@ -601,9 +606,20 @@ end;
|
||||
/// 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;
|
||||
/// ENREGISTRER UNE MODIFICATION - un seul chemin, deux transports.
|
||||
///
|
||||
/// La regle qui tranche : SI UN CONTENEUR LOCAL EXISTE, C'EST LUI L'AUTORITE.
|
||||
/// Un coffre ne ici n'a pas de depot ; un coffre tire d'un depot n'a pas de
|
||||
/// conteneur local. Le cas ou les deux coexistent - un coffre local auquel on
|
||||
/// ajoute ensuite un depot - demande une fusion, et c'est l'etape 4 de la §7.
|
||||
///
|
||||
/// APatch recoit le clair et rend le clair modifie. Il ne peut pas etre
|
||||
/// applique d'avance : le chemin distant doit modifier CE QU'IL VIENT DE
|
||||
/// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps.
|
||||
///
|
||||
/// AReopenUuid vide = revenir a la liste plutot qu'a une entree.
|
||||
procedure TForm8.CommitChange(const ALabel, AComment, AReopenUuid: string;
|
||||
const APatch: TFunc<string, string>);
|
||||
begin
|
||||
// CHAQUE REFUS REND LA MAIN A LA PAGE. Ces gardes se contentaient d'un
|
||||
// message : l'editeur restait ouvert avec ses boutons desactives, donc
|
||||
@@ -614,20 +630,12 @@ begin
|
||||
WebCall('PM.editDone()');
|
||||
Exit;
|
||||
end;
|
||||
if FCurrent.Uuid = '' then
|
||||
begin
|
||||
WebCall('PM.toast("This entry has no uuid - cannot be updated")');
|
||||
WebCall('PM.editDone()');
|
||||
Exit;
|
||||
end;
|
||||
if FSyncPwd = '' then
|
||||
begin
|
||||
WebCall('PM.toast("Sync password missing - open the vault first")');
|
||||
WebCall('PM.toast("Master password missing - open the vault first")');
|
||||
WebCall('PM.editDone()');
|
||||
Exit;
|
||||
end;
|
||||
LUuid := FCurrent.Uuid;
|
||||
LTitle := FCurrent.Display;
|
||||
SetBusy(True);
|
||||
|
||||
TThread.CreateAnonymousThread(
|
||||
@@ -641,11 +649,37 @@ begin
|
||||
LSw: TStopwatch;
|
||||
LDone: Boolean;
|
||||
begin
|
||||
LClient := TSyncClient.Create(FUrl, FToken);
|
||||
LDone := False;
|
||||
LErr := '';
|
||||
LClient := nil;
|
||||
try
|
||||
try
|
||||
if HasLocalContainer then
|
||||
begin
|
||||
// COFFRE LOCAL : rien a tirer, rien a publier, aucun conflit possible
|
||||
// - il n'y a qu'un ecrivain. Le conteneur est reecrit d'abord, le
|
||||
// cache ensuite : l'inverse laisserait un cache en avance sur
|
||||
// l'autorite si l'ecriture echouait entre les deux.
|
||||
LSw := TStopwatch.StartNew;
|
||||
LPlain := DecryptContainerEx(LoadLocalContainer, FSyncPwd,
|
||||
LKey, LSalt, LIter);
|
||||
LNew := APatch(LPlain);
|
||||
Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds]));
|
||||
LSw := TStopwatch.StartNew;
|
||||
LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter);
|
||||
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
|
||||
SaveLocalContainer(LBody);
|
||||
try
|
||||
SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, FPin);
|
||||
except
|
||||
on E: Exception do Log(' cache not written: ' + E.Message);
|
||||
end;
|
||||
Log('save: written locally');
|
||||
LDone := True;
|
||||
end
|
||||
else
|
||||
begin
|
||||
LClient := TSyncClient.Create(FUrl, FToken);
|
||||
for LAttempt := 1 to 3 do
|
||||
begin
|
||||
Log(Format('save: attempt %d - GET', [LAttempt]));
|
||||
@@ -658,14 +692,14 @@ begin
|
||||
end;
|
||||
|
||||
try
|
||||
// UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend la
|
||||
// cle, son sel et les iterations, que le rechiffrement reutilise.
|
||||
// Deriver deux fois le meme mot de passe coutait une seconde fois
|
||||
// quatre a six secondes, pour une cle equivalente.
|
||||
// UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend
|
||||
// la cle, son sel et les iterations, que le rechiffrement
|
||||
// reutilise. Deriver deux fois le meme mot de passe coutait une
|
||||
// seconde fois quatre a six secondes, pour une cle equivalente.
|
||||
LSw := TStopwatch.StartNew;
|
||||
LPlain := DecryptContainerEx(LGet.Container, FSyncPwd,
|
||||
LKey, LSalt, LIter);
|
||||
LNew := PatchEntryField(LPlain, LUuid, 'password', ANewValue);
|
||||
LNew := APatch(LPlain);
|
||||
Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds]));
|
||||
except
|
||||
on E: Exception do
|
||||
@@ -678,13 +712,13 @@ begin
|
||||
LSw := TStopwatch.StartNew;
|
||||
LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter);
|
||||
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
|
||||
LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone');
|
||||
LPut := LClient.Put(LBody, LGet.ETag, AComment);
|
||||
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.
|
||||
// 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, False, FPin);
|
||||
@@ -707,6 +741,7 @@ begin
|
||||
|
||||
if (not LDone) and (LErr = '') then
|
||||
LErr := 'the remote kept changing - try again';
|
||||
end;
|
||||
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
@@ -714,15 +749,15 @@ begin
|
||||
if LDone then
|
||||
begin
|
||||
AdoptVault(LNew);
|
||||
WebCall('PM.toast(' + JsArg('Saved: ' + LTitle) + ')');
|
||||
WebCall('PM.toast(' + JsArg(ALabel) + ')');
|
||||
end
|
||||
else
|
||||
WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')');
|
||||
end);
|
||||
except
|
||||
// FILET. Le rechiffrement et le PUT sont hors du try qui
|
||||
// couvre le dechiffrement : une exception y sortait sans un mot,
|
||||
// et l'echec devenait indiscernable d'une lenteur.
|
||||
// FILET. Le rechiffrement et le PUT sont hors du try qui couvre le
|
||||
// dechiffrement : une exception y sortait sans un mot, et l'echec
|
||||
// devenait indiscernable d'une lenteur.
|
||||
on E: Exception do
|
||||
begin
|
||||
LErr := E.ClassName + ': ' + E.Message;
|
||||
@@ -735,20 +770,57 @@ begin
|
||||
LClient.Free;
|
||||
// LA REMISE EN ETAT EST ICI, PAS DANS LE CHEMIN NOMINAL. Elle etait
|
||||
// apres la boucle : une exception au rechiffrement ou au PUT la
|
||||
// sautait, et la ligne restait sur « Saving... » pour toujours - sans
|
||||
// sautait, et la ligne restait sur "Saving..." pour toujours - sans
|
||||
// message, puisque le message aussi etait dans la partie sautee.
|
||||
// Quoi qu'il arrive au-dessus, l'ecran redevient utilisable.
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
SetBusy(False);
|
||||
ReopenEntryByUuid(LUuid);
|
||||
if AReopenUuid <> '' then ReopenEntryByUuid(AReopenUuid)
|
||||
// Rien a rouvrir : c'est une creation. La page doit quand meme
|
||||
// reprendre la main, sinon un echec laisse son bouton mort.
|
||||
else WebCall('PM.editDone()');
|
||||
end);
|
||||
end;
|
||||
end).Start;
|
||||
end;
|
||||
|
||||
/// Descend les coordonnees du serveur dans les champs de la page.
|
||||
procedure TForm8.SaveEntryPassword(const ANewValue: string);
|
||||
var
|
||||
LUuid, LTitle: string;
|
||||
begin
|
||||
if FCurrent.Uuid = '' then
|
||||
begin
|
||||
WebCall('PM.toast("This entry has no uuid - cannot be updated")');
|
||||
WebCall('PM.editDone()');
|
||||
Exit;
|
||||
end;
|
||||
LUuid := FCurrent.Uuid;
|
||||
LTitle := FCurrent.Display;
|
||||
CommitChange('Saved: ' + LTitle, 'password change from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := PatchEntryField(APlain, LUuid, 'password', ANewValue);
|
||||
end);
|
||||
end;
|
||||
|
||||
procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
|
||||
begin
|
||||
// Une entree sans rien pour la reconnaitre serait une ligne muette dans la
|
||||
// liste, et rien ne permettrait de la retrouver pour la corriger.
|
||||
if (ATitle = '') and (ASite = '') and (AUser = '') then
|
||||
begin
|
||||
WebCall('PM.toast("Give it at least a name")');
|
||||
Exit;
|
||||
end;
|
||||
CommitChange('Added: ' + ATitle, 'new entry from phone', '',
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind);
|
||||
end);
|
||||
end;
|
||||
|
||||
procedure TForm8.PushConfig;
|
||||
var
|
||||
LUrl, LToken: string;
|
||||
@@ -886,6 +958,13 @@ begin
|
||||
// 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 = 'newentry' then
|
||||
CreateEntry(TNetEncoding.URL.Decode(Param('t')),
|
||||
TNetEncoding.URL.Decode(Param('s')),
|
||||
TNetEncoding.URL.Decode(Param('u')),
|
||||
TNetEncoding.URL.Decode(Param('p')),
|
||||
TNetEncoding.URL.Decode(Param('k')))
|
||||
|
||||
else if LAction = 'setpwd' then
|
||||
SaveEntryPassword(TNetEncoding.URL.Decode(Param('v')))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user