Files
Password-Manager/android-client/PMA.VaultPatch.pas
T
r-zakarya d65fe909b4 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.
2026-08-21 14:22:08 +01:00

183 lines
6.5 KiB
ObjectPascal

unit PMA.VaultPatch;
{
Modifier UN champ d'UNE entree dans l'instantane, sans toucher au reste.
LA CONTRAINTE QUI GOUVERNE CETTE UNITE. TVault ne lit qu'une partie des
champs : il ignore `content_b64` des pieces jointes, `created_at`, l'avatar du
compte, les tombstones, et tout ce que le poste ajoutera demain. Reserialiser
le coffre a partir de ce qu'il a compris EFFACERAIT tout cela - et le poste
adopterait cette version amputee au tirage suivant, la fusion etant un
dernier-ecrit-gagne sur `updated_at`. La perte serait silencieuse et
definitive.
Donc on ne reconstruit rien : on prend le JSON RECU, on change une valeur
dedans, et on rend le reste tel quel. Ce que le telephone ne comprend pas
voyage intact, par construction plutot que par vigilance.
COROLLAIRE SUR LA FUSION. Comme le patch s'applique a l'instantane qu'on
vient de tirer, il n'y a pas de fusion a ecrire : ce qui n'est pas touche est
deja la version la plus recente du serveur. Le seul conflit possible est
qu'un autre appareil publie entre notre lecture et notre ecriture, et Gitea
le refuse par le sha (409) - il suffit alors de recommencer.
}
interface
uses
System.SysUtils, System.JSON, System.DateUtils;
type
EPMAPatch = class(Exception);
/// Remplace AField de l'entree AUuid par AValue, et bump `updated_at`.
/// Rend le JSON complet, tout le reste inchange. Leve si l'entree est absente -
/// ce qui veut dire que le distant ne la contient pas (supprimee ailleurs), et
/// ecrire quand meme la ferait reapparaitre.
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
/// Un instantane vide, au nom de ce compte. Sert a l'inscription depuis le
/// telephone : meme forme que ce qu'ecrit le poste, pour qu'un coffre ne
/// 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.
function NowUtcIso: string;
implementation
function NowUtcIso: string;
begin
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"',
TTimeZone.Local.ToUniversalTime(Now));
end;
function NewEmptySnapshot(const AUsername: string): string;
var
LRoot: TJSONObject;
begin
LRoot := TJSONObject.Create;
try
// Les memes cles que buildSyncSnapshot, tableaux vides compris : un lecteur
// qui trouve la cle absente et un lecteur qui la trouve vide ne se
// comportent pas forcement pareil, et ce coffre sera lu par le poste.
LRoot.AddPair('version', TJSONNumber.Create(1));
LRoot.AddPair('snapshot_at', NowUtcIso);
LRoot.AddPair('username', AUsername);
LRoot.AddPair('folders', TJSONArray.Create);
LRoot.AddPair('avatar_b64', '');
LRoot.AddPair('entries', TJSONArray.Create);
LRoot.AddPair('tombstones', TJSONArray.Create);
Result := LRoot.ToJSON;
finally
LRoot.Free;
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;
LEntries: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver chez le distant');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// RemovePair puis AddPair : AddPair seul ajouterait un DOUBLON, et le
// lecteur prendrait le premier - donc l'ancienne valeur.
LEntry.RemovePair(AField).Free;
LEntry.AddPair(AField, AValue);
// Sans ce bump, la modification voyage mais les autres appareils
// l'ignorent : leur arbitrage compare `updated_at`, et le notre serait
// reste plus ancien que le leur.
LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso);
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt(
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
end.