spiegel-keyman/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas
Marc Durdin 6eefb3a771 fix(developer): EncodeURL was not handling spaces
Fixes #7810.

This addresses a regression introduced in #7631, where URL parameters
with spaces would be encoded into `+` instead of `%20`. Looking a bit
deeper at the Delphi `TNetEncoding.URL.Encode` function I realised that
it was entirely inadequate. Some guy named Marc Durdin wrote a blog a
good few years ago about the problem, and that's what I ended up using.

This encoding issue caused filenames with spaces (by default, project
paths in Developer have spaces) to give a 404 when editing a touch
layout, which meant that the touch keyboards could not be saved.

Also fixes KEYMAN-DEVELOPER-74, where the + encoding caused multiple
entries to appear in the filename cache.

Amusing to Google this problem, find solid answer on SO, which pointed
to my very own blog. Embarrassing that my own code didn't already
include my own fix.

A secondary issue is also fixed here, where request parameters were
double-decoded for formencoded POST requests. The fix for broken URL
encodings was only required for GET requests.

This also showed up in KEYMAN-DEVELOPER-74, with double-encoded paths
being registered as source files.
2022-11-26 07:09:53 +07:00

342 lines
9.7 KiB
ObjectPascal

unit Keyman.Developer.System.HttpServer.AppSource;
interface
uses
System.Generics.Collections,
System.StrUtils,
System.SysUtils,
IdContext,
IdCustomHTTPServer,
IdHTTPServer,
Keyman.System.HttpServer.Base;
// /app/source
type
TAppSource = record
Filename, Data: string;
end;
TAppSourceHttpResponder = class(TBaseHttpResponder)
private
FSources: TThreadList<TAppSource>;
procedure RespondGet(const AFilename: string; AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
procedure RespondPost(const AFilename: string; AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
procedure RespondTouchEditor(const AFilename: string; AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
procedure RespondTouchEditorLib(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
function TrySetSource(const Filename, Data: string): Boolean;
procedure RespondTouchEditorState(const AFilename: string;
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
public
constructor Create;
destructor Destroy; override;
procedure ProcessRequest(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
procedure RegisterSource(const Filename, Data: string; Update: Boolean = False);
procedure UnregisterSource(const Filename: string);
function IsSourceRegistered(const Filename: string): Boolean;
function GetSource(const Filename: string): string;
function TryGetSource(const Filename: string; var Data: string): Boolean;
procedure SetSource(const Filename, Data: string);
end;
implementation
uses
Xml.XMLDoc,
Xml.XMLIntf,
RedistFiles;
{ TAppSourceHttpResponder }
{*
Gets the current source content from the filename requested
}
constructor TAppSourceHttpResponder.Create;
begin
inherited Create;
FSources := TThreadList<TAppSource>.Create;
end;
destructor TAppSourceHttpResponder.Destroy;
var
T: TList<TAppSource>;
begin
T := FSources.LockList;
try
// Note: Unlike regular functions, Assert has short-circuit evaluation
// intrinsics on the first param which makes it safe to dereference T[0] in
// the second parameter.
Assert(T.Count = 0, 'TAppSourceHttpResponder.Sources should be empty at destruction '+
'(T.Count='+IntToStr(T.Count)+', T[0].Filename='+T[0].Filename+')');
finally
FSources.UnlockList;
end;
FSources.Free;
inherited Destroy;
end;
procedure TAppSourceHttpResponder.ProcessRequest(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
var
Filename: string;
begin
// /app/source?Filename=xxxx
// POST = update
// GET = retrieve
if ARequestInfo.Document = '/app/source/file' then
begin
// TODO: We should be passing a token to the browser for future POST security
Filename := CrackUTF8ZeroExtendedString(ARequestInfo.CommandType, ARequestInfo.Params.Values['Filename']);
if ARequestInfo.CommandType = hcGET then
begin
RespondGet(Filename, AContext, ARequestInfo, AResponseInfo);
end
else if ARequestInfo.CommandType = hcPOST then
begin
RespondPost(Filename, AContext, ARequestInfo, AResponseInfo);
end
else
Respond404(AContext, ARequestInfo, AResponseInfo);
end
else if ARequestInfo.Document = '/app/source/toucheditor' then
begin
// Respond files?
Filename := CrackUTF8ZeroExtendedString(ARequestInfo.CommandType, ARequestInfo.Params.Values['Filename']);
RespondTouchEditor(Filename, AContext, ARequestInfo, AResponseInfo);
end
else if ARequestInfo.Document = '/app/source/toucheditor/state' then
begin
Filename := CrackUTF8ZeroExtendedString(ARequestInfo.CommandType, ARequestInfo.Params.Values['Filename']);
RespondTouchEditorState(Filename, AContext, ARequestInfo, AResponseInfo);
end
else if ARequestInfo.Document.StartsWith('/app/source/toucheditor/lib/') then
begin
RespondTouchEditorLib(AContext, ARequestInfo, AResponseInfo);
end
else
begin
Respond404(AContext, ARequestInfo, AResponseInfo);
Exit;
end;
end;
procedure TAppSourceHttpResponder.RespondTouchEditorState(const AFilename: string;
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
var
FData: string;
begin
if ARequestInfo.CommandType = hcGET then
begin
AResponseInfo.ContentType := 'application/json';
AResponseInfo.Charset := 'UTF-8';
if TryGetSource(AFilename + '#state', FData)
then AResponseInfo.ContentText := FData
else AResponseInfo.ContentText := '{}';
end
else if ARequestInfo.CommandType = hcPOST then
begin
// Posted data is already decoded correctly
FData := ARequestInfo.Params.Values['State'];
RegisterSource(AFilename + '#state', FData, True);
end;
end;
procedure TAppSourceHttpResponder.RespondTouchEditor(const AFilename: string;
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
var
FStyleSheet: IXMLDocument;
root: IXMLNode;
output: Widestring;
FData: string;
begin
// TODO: data will be passed separate call from touch editor later
if not TryGetSource(AFilename, FData) then
begin
Respond404(AContext, ARequestInfo, AResponseInfo);
Exit;
end;
with NewXMLDocument do
begin
root := AddChild('TouchLayoutBuilder');
//root.AddChild('FileName').NodeValue := FBaseFileName;
root.AddChild('LibPath').NodeValue := '/app/source/toucheditor/lib/';//ConvertPathToFileURL(GetLayoutBuilderPath);
root.AddChild('LayoutJS').NodeValue := FData;
FStyleSheet := LoadXMLDocument(GetLayoutBuilderPath + 'builder.xsl');
root.TransformNode(FStyleSheet.DocumentElement, output);
end;
AResponseInfo.ContentType := 'text/html';
AResponseInfo.Charset := 'UTF-8';
AResponseInfo.ContentText := output;
end;
procedure TAppSourceHttpResponder.RespondTouchEditorLib(
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
var
doc: string;
begin
doc := ARequestInfo.Document;
Delete(doc, 1, Length('/app/source/toucheditor/lib/'));
if Pos('..', doc) > 0 then
Respond404(AContext, ARequestInfo, AResponseInfo)
else
RespondFile(GetLayoutBuilderPath + doc, AContext, ARequestInfo, AResponseInfo);
end;
procedure TAppSourceHttpResponder.SetSource(const Filename, Data: string);
begin
Assert(TrySetSource(Filename, Data),
'TAppSourceHttpResponder.SetSource was asked for a file that was not registered: '+Filename);
end;
function TAppSourceHttpResponder.TrySetSource(const Filename, Data: string): Boolean;
var
i: Integer;
T: TList<TAppSource>;
S: TAppSource;
begin
T := FSources.LockList;
try
for i := 0 to T.Count - 1 do
if T[i].Filename = Filename then
begin
S := T[i];
S.Data := Data;
T[i] := S;
Exit(True);
end;
finally
FSources.UnlockList;
end;
Result := False;
end;
procedure TAppSourceHttpResponder.RespondGet(const AFilename: string;
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
var
FData: string;
begin
if TryGetSource(AFilename, FData) then
begin
AResponseInfo.ContentType := 'application/json';
AResponseInfo.CharSet := 'UTF-8';
AResponseInfo.ContentText := FData;
end
else
Respond404(AContext, ARequestInfo, AResponseInfo);
end;
procedure TAppSourceHttpResponder.RespondPost(const AFilename: string;
AContext: TIdContext; ARequestInfo: TIdHTTPRequestInfo;
AResponseInfo: TIdHTTPResponseInfo);
begin
if TrySetSource(AFilename, ARequestInfo.Params.Values['Data']) then
begin
AResponseInfo.ContentType := 'application/json';
AResponseInfo.CharSet := 'UTF-8';
AResponseInfo.ContentText := '{result: true}';
end
else
Respond404(AContext, ARequestInfo, AResponseInfo);
end;
function TAppSourceHttpResponder.TryGetSource(const Filename: string; var Data: string): Boolean;
var
i: Integer;
T: TList<TAppSource>;
begin
T := FSources.LockList;
try
for i := 0 to T.Count - 1 do
if T[i].Filename = Filename then
begin
Data := T[i].Data;
Exit(True);
end;
finally
FSources.UnlockList;
end;
Result := False;
end;
function TAppSourceHttpResponder.GetSource(const Filename: string): string;
begin
Assert(TryGetSource(Filename, Result),
'TAppSourceHttpResponder.GetSource was asked for a file that was not registered: '+Filename);
end;
function TAppSourceHttpResponder.IsSourceRegistered(const Filename: string): Boolean;
var
Data: string;
begin
Result := TryGetSource(Filename, Data);
end;
procedure TAppSourceHttpResponder.RegisterSource(const Filename, Data: string; Update: Boolean);
var
i: Integer;
T: TList<TAppSource>;
S: TAppSource;
begin
T := FSources.LockList;
try
S.Filename := Filename;
S.Data := Data;
for i := 0 to T.Count - 1 do
if T[i].Filename = Filename then
begin
if Update then
T[i] := S
else
Assert(False, 'TAppSourceHttpResponder.RegisterSource was called for a file that is already registered: '+Filename);
Exit;
end;
T.Add(S);
finally
FSources.UnlockList;
end;
end;
procedure TAppSourceHttpResponder.UnregisterSource(const Filename: string);
var
i: Integer;
T: TList<TAppSource>;
begin
T := FSources.LockList;
try
for i := 0 to T.Count - 1 do
if T[i].Filename = Filename then
begin
T.Delete(i);
Exit;
end;
finally
FSources.UnlockList;
end;
Assert(False, 'TAppSourceHttpResponder.UnregisterSource was called for a file that is not registered: '+Filename);
end;
end.