8315ce9230
Making every field editable put a note's body behind an <input>, and HTML's value sanitization removes CR and LF without a word. The formatting would have been lost on the phone and published to the repository at the next save - silent, and irreversible once the desktop pulled it. Multi-line is marked on the row in Delphi, like the field name, rather than guessed from the caption or from whether the value happens to contain a newline today: a single-line note one wants to make multi-line deserves the same control. The row wraps through a class set when the editor opens, not through :has() - that selector does not exist before Chrome 105, and an older engine would ignore the rule without saying so.
305 lines
11 KiB
ObjectPascal
305 lines
11 KiB
ObjectPascal
unit PMA.WebUI;
|
|
{
|
|
La page HTML et les donnees qu'on lui envoie.
|
|
|
|
POURQUOI HTML. Les controles FMX n'ont pas de gabarit fiable sur mobile :
|
|
la journee a vu disparaitre tour a tour la ligne de liste, l'invite de
|
|
recherche, la ligne de detail, selon le style charge. Le reste - arrondis,
|
|
ordre d'alignement, HitTest qui avale un appui, vues natives qui se peignent
|
|
par-dessus - se re-decouvre a chaque ecran. Le moteur du systeme rend la
|
|
meme interface en quelques lignes de CSS, gere l'arabe et le bidirectionnel
|
|
seul, et c'est deja le langage de l'interface du poste Windows.
|
|
|
|
CE QUI RESTE EN DELPHI : Keystore, biometrie, presse-papiers sensible,
|
|
FLAG_SECURE, reseau et dechiffrement. Le moteur n'est qu'une surface
|
|
d'affichage - il ne detient jamais de quoi ouvrir le coffre.
|
|
|
|
CE QUI PART VERS LA PAGE. La liste porte les noms, sites et identifiants,
|
|
soit ce qui est deja a l'ecran, et AUCUN mot de passe : copier depuis une
|
|
ligne envoie un indice, Delphi copie depuis le coffre. Seule l'entree
|
|
ouverte a ses valeurs dans le tas JS, une a la fois.
|
|
}
|
|
|
|
interface
|
|
|
|
uses
|
|
PMA.Vault;
|
|
|
|
/// La page, telle qu'embarquee dans le .res. ASCII pur - voir l'en-tete de
|
|
/// app.html : le chemin de chargement de FMX ne transporte pas l'encodage.
|
|
function PageHtml: string;
|
|
|
|
/// Ce que la liste affiche et cherche. Les indices sont ceux du coffre, pas
|
|
/// ceux de la liste : la page filtre et trie sans rien redemander, donc les
|
|
/// deux ne coincident plus des la premiere frappe.
|
|
function VaultToJson(AVault: TVault): string;
|
|
|
|
/// Les lignes d'une entree, dans l'ordre d'affichage.
|
|
function DetailToJson(const AEntry: TVaultEntry): string;
|
|
|
|
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
|
|
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
|
|
/// Faux si le rang ne designe rien.
|
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
|
out ACaption, AValue: string): Boolean; overload;
|
|
/// Variante qui rend aussi le rang de la piece jointe, ou -1.
|
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
|
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean; overload;
|
|
|
|
implementation
|
|
|
|
uses
|
|
// System.Types pour RT_RCDATA, que Winapi.Windows fournirait sur un poste.
|
|
System.SysUtils, System.Classes, System.Types, System.JSON, System.Character;
|
|
|
|
{$R 'web\web.res' 'web\web.rc'}
|
|
|
|
function PageHtml: string;
|
|
var
|
|
LStream: TResourceStream;
|
|
LBytes: TBytes;
|
|
begin
|
|
LStream := TResourceStream.Create(HInstance, 'PMAPP', RT_RCDATA);
|
|
try
|
|
SetLength(LBytes, LStream.Size);
|
|
LStream.ReadBuffer(LBytes, LStream.Size);
|
|
Result := TEncoding.UTF8.GetString(LBytes);
|
|
finally
|
|
LStream.Free;
|
|
end;
|
|
end;
|
|
|
|
function HumanSize(ABytes: Int64): string;
|
|
begin
|
|
if ABytes >= 1024 * 1024 then
|
|
Result := Format('%.1f MB', [ABytes / (1024 * 1024)])
|
|
else if ABytes >= 1024 then
|
|
Result := Format('%.0f KB', [ABytes / 1024])
|
|
else
|
|
Result := Format('%d B', [ABytes]);
|
|
end;
|
|
|
|
/// Premiere lettre du nom affiche, pour la pastille sans icone.
|
|
function Initial(const AName: string): string;
|
|
var
|
|
LCh: Char;
|
|
begin
|
|
Result := '?';
|
|
for LCh in AName do
|
|
if LCh.IsLetterOrDigit then Exit(LCh.ToUpper);
|
|
end;
|
|
|
|
function VaultToJson(AVault: TVault): string;
|
|
var
|
|
LRoot: TJSONObject;
|
|
LEntries, LFolders: TJSONArray;
|
|
LObj: TJSONObject;
|
|
LIdx: TArray<Integer>;
|
|
I: Integer;
|
|
E: TVaultEntry;
|
|
LFolder: string;
|
|
begin
|
|
LRoot := TJSONObject.Create;
|
|
try
|
|
LEntries := TJSONArray.Create;
|
|
LRoot.AddPair('entries', LEntries);
|
|
|
|
// Search('') rend les vivantes, deja triees par nom affiche. La page
|
|
// conserve cet ordre : trier de nouveau en JS demanderait de repliquer
|
|
// la meme convention de comparaison pour rien.
|
|
LIdx := AVault.Search('');
|
|
for I := 0 to High(LIdx) do
|
|
begin
|
|
E := AVault.Entry(LIdx[I]);
|
|
LObj := TJSONObject.Create;
|
|
LObj.AddPair('i', TJSONNumber.Create(LIdx[I]));
|
|
LObj.AddPair('name', E.Display);
|
|
// Le site s'il existe, sinon l'identifiant : deux fois la meme chaine
|
|
// sur deux lignes n'apprendrait rien.
|
|
if E.Site <> '' then LObj.AddPair('sub', E.Site)
|
|
else if E.Username <> E.Display then LObj.AddPair('sub', E.Username)
|
|
else LObj.AddPair('sub', '');
|
|
LObj.AddPair('folder', E.Folder);
|
|
// Corpus de recherche pre-assemble et en minuscules : la page cherche a
|
|
// chaque frappe, autant ne pas recomposer la meme chaine a chaque fois.
|
|
// Il couvre ce que couvrait TVault.Search - nom, site, identifiant,
|
|
// etiquettes - donc l'identifiant reste trouvable meme quand la ligne
|
|
// affiche le site.
|
|
LObj.AddPair('s', (E.Title + ' ' + E.Site + ' ' + E.Username + ' ' +
|
|
E.Tags).ToLower);
|
|
LObj.AddPair('icon', E.IconB64);
|
|
LObj.AddPair('initial', Initial(E.Display));
|
|
LObj.AddPair('hasUser', TJSONBool.Create(E.Username <> ''));
|
|
LObj.AddPair('isNote', TJSONBool.Create(E.IsNote));
|
|
LEntries.AddElement(LObj);
|
|
end;
|
|
|
|
LFolders := TJSONArray.Create;
|
|
LRoot.AddPair('folders', LFolders);
|
|
for LFolder in AVault.Folders do
|
|
LFolders.Add(LFolder);
|
|
|
|
Result := LRoot.ToJSON;
|
|
finally
|
|
LRoot.Free;
|
|
end;
|
|
end;
|
|
|
|
/// Les lignes d'une entree. UN SEUL ENDROIT decide de leur composition et de
|
|
/// leur ordre : DetailToJson et DetailValues en dependent tous les deux, et
|
|
/// un ecart entre eux ferait copier la valeur de la ligne voisine.
|
|
type
|
|
TDetailRow = record
|
|
Caption, Value: string;
|
|
Secret: Boolean;
|
|
/// LE NOM DU CHAMP DANS L'INSTANTANE, vide si la ligne ne se modifie pas.
|
|
///
|
|
/// Il porte les deux choses a la fois : qu'on puisse editer, et quoi
|
|
/// editer. Marque ICI plutot que devine du libelle cote page : un intitule
|
|
/// est de la prose, et le jour ou il change la page enverrait un nom de
|
|
/// champ inexistant - ou, pire, cesserait silencieusement de proposer la
|
|
/// modification.
|
|
///
|
|
/// Supprimer une entree n'y figure pas : cela demande des tombstones, et
|
|
/// c'est l'etape 3 de la §7.
|
|
Field: string;
|
|
/// Rang de la piece jointe dans l'entree, ou -1. Il ne se deduit pas du
|
|
/// rang de la ligne : les champs personnalises s'intercalent avant.
|
|
AttachIndex: Integer;
|
|
/// La valeur tient sur plusieurs lignes. Marque ICI, pas devine du libelle
|
|
/// ni du contenu : un <input> APPLIQUE la sanitisation de HTML et retire
|
|
/// les retours a la ligne en silence. Une note modifiee depuis le telephone
|
|
/// y perdait sa mise en forme, et la perte partait au depot.
|
|
Multiline: Boolean;
|
|
end;
|
|
|
|
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
|
|
var
|
|
LRows: TArray<TDetailRow>;
|
|
I: Integer;
|
|
|
|
procedure Add(const ACaption, AValue: string; ASecret: Boolean;
|
|
const AField: string = ''; AAttach: Integer = -1;
|
|
AMultiline: Boolean = False);
|
|
begin
|
|
// Une ligne vide n'apprend rien - SAUF si elle se remplit. Un identifiant
|
|
// absent qu'on ne peut pas ajouter serait une impasse.
|
|
if (AValue = '') and (AField = '') then Exit;
|
|
SetLength(LRows, Length(LRows) + 1);
|
|
LRows[High(LRows)].Caption := ACaption;
|
|
LRows[High(LRows)].Value := AValue;
|
|
LRows[High(LRows)].Secret := ASecret;
|
|
LRows[High(LRows)].Field := AField;
|
|
LRows[High(LRows)].AttachIndex := AAttach;
|
|
LRows[High(LRows)].Multiline := AMultiline;
|
|
end;
|
|
|
|
begin
|
|
Add('Name', E.Title, False, 'title');
|
|
// 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.
|
|
//
|
|
// Un site et un identifiant n'ont aucun sens pour une note. Les proposer
|
|
// vides reviendrait a inviter a remplir ce que rien n'affichera jamais.
|
|
if E.IsNote then
|
|
Add('Content', E.Password, False, 'password', -1, True)
|
|
else
|
|
begin
|
|
Add('Site', E.Site, False, 'site');
|
|
Add('Username', E.Username, False, 'username');
|
|
Add('Password', E.Password, True, 'password');
|
|
// LE SECRET, pas le code. Le code a sa propre case en haut, calculee et
|
|
// renouvelee toutes les trente secondes ; c'est le secret qui se pose, se
|
|
// change et se retire, et sans cette ligne il n'y avait aucun moyen de le
|
|
// faire depuis le telephone. Masque comme un mot de passe : il vaut le
|
|
// second facteur en entier.
|
|
Add('2FA secret', E.Totp, True, 'totp_secret');
|
|
end;
|
|
|
|
for I := 0 to High(E.Fields) do
|
|
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
|
|
|
|
// Les pieces jointes ne sont pas encore enregistrables sur le telephone,
|
|
// mais taire leur existence serait pire : on saurait sur le poste qu'une
|
|
// entree porte un document, et pas ici.
|
|
for I := 0 to High(E.Attachments) do
|
|
Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes),
|
|
E.Attachments[I].FileName, False, '', I);
|
|
|
|
// DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil.
|
|
// Le dossier est une chaine simple, choisie parmi celles qui existent - la
|
|
// page en fait une liste plutot qu'un champ libre, sinon une faute de frappe
|
|
// rangerait l'entree dans un dossier que rien n'affiche.
|
|
// Les etiquettes, elles, sont un TABLEAU dans l'instantane : les ecrire comme
|
|
// une chaine casserait leur forme pour le poste. Lecture seule tant que le
|
|
// patch ne sait pas ecrire un tableau.
|
|
Add('Folder', E.Folder, False, 'folder');
|
|
Add('Tags', E.Tags, False);
|
|
Add('Type', E.Template, False);
|
|
// Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater
|
|
// demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
|
|
Add('Updated', E.UpdatedAt, False);
|
|
|
|
Result := LRows;
|
|
end;
|
|
|
|
function DetailToJson(const AEntry: TVaultEntry): string;
|
|
var
|
|
LRoot: TJSONObject;
|
|
LArr: TJSONArray;
|
|
LObj: TJSONObject;
|
|
LRows: TArray<TDetailRow>;
|
|
I: Integer;
|
|
begin
|
|
LRows := BuildRows(AEntry);
|
|
LRoot := TJSONObject.Create;
|
|
try
|
|
LRoot.AddPair('title', AEntry.Display);
|
|
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
|
|
LArr := TJSONArray.Create;
|
|
LRoot.AddPair('rows', LArr);
|
|
for I := 0 to High(LRows) do
|
|
begin
|
|
LObj := TJSONObject.Create;
|
|
LObj.AddPair('c', LRows[I].Caption);
|
|
LObj.AddPair('v', LRows[I].Value);
|
|
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
|
|
// 'e' porte le nom du champ, et sa presence vaut "modifiable".
|
|
if LRows[I].Field <> '' then LObj.AddPair('e', LRows[I].Field);
|
|
// 'a' porte le rang de la piece jointe : sa presence vaut
|
|
// "enregistrable", et la page y met le bouton de telechargement.
|
|
if LRows[I].AttachIndex >= 0 then
|
|
LObj.AddPair('a', TJSONNumber.Create(LRows[I].AttachIndex));
|
|
if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True));
|
|
LArr.AddElement(LObj);
|
|
end;
|
|
Result := LRoot.ToJSON;
|
|
finally
|
|
LRoot.Free;
|
|
end;
|
|
end;
|
|
|
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
|
out ACaption, AValue: string): Boolean;
|
|
var
|
|
LAttach: Integer;
|
|
begin
|
|
Result := DetailRowAt(AEntry, AIndex, ACaption, AValue, LAttach);
|
|
end;
|
|
|
|
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
|
|
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean;
|
|
var
|
|
LRows: TArray<TDetailRow>;
|
|
begin
|
|
AAttachIndex := -1;
|
|
LRows := BuildRows(AEntry);
|
|
Result := (AIndex >= 0) and (AIndex <= High(LRows));
|
|
if not Result then Exit;
|
|
ACaption := LRows[AIndex].Caption;
|
|
AValue := LRows[AIndex].Value;
|
|
AAttachIndex := LRows[AIndex].AttachIndex;
|
|
end;
|
|
|
|
end.
|