Close the remaining installer review findings
Eleven findings, grouped by the root cause each belongs to. Wizard input reaching a command line unchecked (ShopDBFlask.iss). Port fields were spliced in bare and arrive as [int] parameters, so a blank or mistyped port shifted every argument after it; both port fields are now validated as 1-65535 digits. A path ending in a backslash, which is what a drive root looks like, ended its argument with \" and CommandLineToArgvW read that as an escaped quote, so paths are now quoted through a helper that doubles the trailing backslash. A drive root is refused outright as well: uninstall deletes the application directory recursively, so installing to D:\ would have wiped the drive on removal. The password handoff was written with SaveStringToFile, which writes an AnsiString, and read back as UTF-8, so a correct non-ASCII password was reported as wrong; it now goes out as UTF-8 without a BOM. Launching without checking the result. Plugin deregistration invoked "flask plugin uninstall" without --yes, and the command carries a click confirmation_option that aborts with exit 1 when nothing can answer the prompt, so it could never once have succeeded; the bare 2>&1 under EAP Stop then turned that into a terminating error which the catch downgraded to a warning while the plugin directory was deleted regardless. It now passes --yes, brackets the error preference, restores the location in a finally, and keeps the code on disk unless deregistration actually succeeded. MarkShortcutRunAs had four quotes where it needed three, which kept the whole command inside one Pascal literal so LnkPath was never interpolated and no shortcut ever got the elevation flag; its exit code is now logged too. Comparing IIS physical paths as raw strings. IIS stores the path as typed, so it may carry environment variables or a trailing backslash. A Test-SamePath helper now normalises both sides. That closes a real hazard in uninstall, which matched applications on alias alone and would remove an unrelated application of the same name under another site, unattended, since -OnFailure never suppresses the confirmation. Accepting existing IIS state without reconciling it. "Site already exists" took the site however it was, so re-running with a different port left the old binding while CORS_ORIGINS, the firewall rule and the smoke test all used the new one, failing a working server. It now refuses with both ports named rather than silently re-binding, and refuses a site of that name serving a different directory. Preflight rows drawn past the panel. The failures loop had no cap at all and the warnings loop capped at 6, a number unrelated to the panel, which holds about three rows. The cap is now measured from the panel height, applies to both loops, and the footer counts what was actually left out instead of inferring it. Also: a failed upgrade now says the application pool is still stopped and how to start it, rather than only "part-configured", since stage 2 stops a pool that was serving. It is deliberately not restarted automatically, because after a stage 3 failure the deployed code and the schema may disagree. shopdb-admin Restart-App starts a stopped pool or site instead of recycling, which is a no-op on a stopped pool and then reported the application as unresponsive. A dead Write-Log line that parsed as three arguments is gone, and a preflight warning no longer tells the operator to add a directory to a compiled exe.
This commit is contained in:
@@ -655,7 +655,7 @@ end;
|
||||
// If nothing is wrong the page collapses to a single reassuring statement.
|
||||
procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean);
|
||||
var
|
||||
I, Y, Shown, Fails, Warns, Passes: Integer;
|
||||
I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer;
|
||||
Parts: TArrayOfString;
|
||||
Check, Detail, Fix: String;
|
||||
begin
|
||||
@@ -663,6 +663,16 @@ begin
|
||||
Y := ScaleY(4);
|
||||
Shown := 0;
|
||||
|
||||
// How many rows actually FIT, measured, not guessed. The old cap of 6 bore no
|
||||
// relation to the panel, which holds roughly three: at four or five notes the
|
||||
// rows below were drawn past the bottom edge and silently vanished - taking
|
||||
// with them the warning that the bundled MySQL collides on port 3306, which is
|
||||
// exactly what the operator needs before the very next page.
|
||||
RowHeight := ScaleY(46) + ScaleY(ROW_GAP);
|
||||
if RowHeight < 1 then RowHeight := 1;
|
||||
MaxRows := DetailPanel.Height div RowHeight;
|
||||
if MaxRows < 1 then MaxRows := 1;
|
||||
|
||||
// Blockers first, then warnings - a second pass rather than one, so severity
|
||||
// ordering does not depend on the order the checks happen to run in.
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
@@ -676,6 +686,9 @@ begin
|
||||
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
// Blockers are capped too. Without this the FAIL loop drew every failure,
|
||||
// however many, straight off the bottom of the panel.
|
||||
if Shown >= MaxRows then Break;
|
||||
Parts := StringSplit(Lines[I], ['|'], stAll);
|
||||
if GetArrayLength(Parts) < 4 then Continue;
|
||||
if Parts[0] <> 'FAIL' then Continue;
|
||||
@@ -690,7 +703,7 @@ begin
|
||||
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
if Shown >= 6 then Break;
|
||||
if Shown >= MaxRows then Break;
|
||||
Parts := StringSplit(Lines[I], ['|'], stAll);
|
||||
if GetArrayLength(Parts) < 4 then Continue;
|
||||
if Parts[0] <> 'WARN' then Continue;
|
||||
@@ -727,8 +740,13 @@ begin
|
||||
+ 'verified. Nothing needs your attention.');
|
||||
end;
|
||||
|
||||
if (Shown >= 6) and (Warns > 6 - Fails) then
|
||||
FooterText.Caption := 'Some notes are not shown. The full check is in the install log.'
|
||||
// Count what was actually left out rather than inferring it from the old cap,
|
||||
// which under-reported: with 2 failures and 5 warnings it claimed everything
|
||||
// was shown while four rows had been dropped.
|
||||
Hidden := (Fails + Warns) - Shown;
|
||||
if Hidden > 0 then
|
||||
FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. '
|
||||
+ 'All of them are in the install log.'
|
||||
else
|
||||
FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
|
||||
end;
|
||||
@@ -837,10 +855,76 @@ begin
|
||||
Log('[shopdb] pre-fill base directory: ' + Result);
|
||||
end;
|
||||
|
||||
// Everything typed in the wizard ends up on a command line that
|
||||
// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or
|
||||
// [string]. A blank, a space, or a trailing backslash therefore has to be caught
|
||||
// HERE - by the time the script sees it, the arguments have already shifted.
|
||||
|
||||
function IsValidPort(const S: String): Boolean;
|
||||
var
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if (S = '') or (Length(S) > 5) then Exit;
|
||||
for I := 1 to Length(S) do
|
||||
if (S[I] < '0') or (S[I] > '9') then Exit; // also rejects spaces and signs
|
||||
N := StrToIntDef(S, -1);
|
||||
Result := (N >= 1) and (N <= 65535);
|
||||
end;
|
||||
|
||||
function IsAsciiOnly(const S: String): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := True;
|
||||
for I := 1 to Length(S) do
|
||||
if Ord(S[I]) > 126 then
|
||||
begin
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
// A path that ends in a backslash - a drive root such as D:\ - closes the
|
||||
// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The
|
||||
// argument never terminates and every argument after it shifts one place.
|
||||
// Doubling the backslash inside the quotes is the documented way out.
|
||||
function QuotePathArg(const P: String): String;
|
||||
begin
|
||||
if (P <> '') and (P[Length(P)] = '\') then
|
||||
Result := '"' + P + '\' + '"'
|
||||
else
|
||||
Result := '"' + P + '"';
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
|
||||
// A drive root is refused outright rather than quoted around. Uninstall
|
||||
// removes the application directory recursively, so accepting D:\ here would
|
||||
// mean uninstalling ShopDB-Flask wipes the whole drive.
|
||||
if CurPageID = wpSelectDir then
|
||||
begin
|
||||
if Length(WizardDirValue) <= 3 then
|
||||
begin
|
||||
MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10
|
||||
+ 'Removing ShopDB-Flask deletes the folder it was installed into, '
|
||||
+ 'so installing to a drive root would delete everything on that '
|
||||
+ 'drive when it is uninstalled.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = SitePage.ID then
|
||||
begin
|
||||
if not IsValidPort(SitePage.Values[1]) then
|
||||
begin
|
||||
MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = wpWelcome then
|
||||
RunPreflight;
|
||||
|
||||
@@ -872,6 +956,13 @@ begin
|
||||
begin
|
||||
MsgBox('Enter the database host.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end
|
||||
else if not IsValidPort(DbDetailsPage.Values[1]) then
|
||||
begin
|
||||
MsgBox('Enter the database port as a number between 1 and 65535.'
|
||||
+ #13#10#13#10 + 'MySQL uses 3306 unless it was changed.',
|
||||
mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
@@ -1056,22 +1147,31 @@ var
|
||||
Cmd: String;
|
||||
begin
|
||||
if not FileExists(LnkPath) then Exit;
|
||||
// THREE quotes each side, not four. Four is an escaped quote twice over, which
|
||||
// keeps the whole expression inside one Pascal literal: LnkPath was never
|
||||
// interpolated, PowerShell got the bare word "LnkPath", failed to parse, and
|
||||
// no shortcut ever received the elevation flag. Self-elevation hid it - the
|
||||
// only symptom was a second window after an extra prompt.
|
||||
Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
|
||||
+ '$p='''' + LnkPath + ''''; '
|
||||
+ '$p=''' + LnkPath + '''; '
|
||||
+ '$b=[IO.File]::ReadAllBytes($p); '
|
||||
+ '$b[21]=$b[21] -bor 0x20; '
|
||||
+ '[IO.File]::WriteAllBytes($p,$b)"';
|
||||
Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if ResultCode <> 0 then
|
||||
Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit '
|
||||
+ IntToStr(ResultCode) + '); the console will self-elevate instead');
|
||||
end;
|
||||
|
||||
function RunInstallStages: String;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
PwFile, Args, Common: String;
|
||||
PwLines: TArrayOfString;
|
||||
begin
|
||||
Result := '';
|
||||
Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' +
|
||||
' -AppRoot "' + ExpandConstant('{app}') + '"' +
|
||||
Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) +
|
||||
' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) +
|
||||
' -SiteHost "' + SitePage.Values[0] + '"' +
|
||||
' -SitePort ' + SitePage.Values[1] +
|
||||
' -OnFailure never' +
|
||||
@@ -1110,7 +1210,13 @@ begin
|
||||
if DbCredsPage.Values[1] <> '' then
|
||||
begin
|
||||
PwFile := ExpandConstant('{tmp}\dbpw.txt');
|
||||
SaveStringToFile(PwFile, DbCredsPage.Values[1] + #13#10, False);
|
||||
// UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer
|
||||
// reads this back with -Encoding UTF8 - so any non-ASCII character in the
|
||||
// password came out mangled and the database rejected a password that was
|
||||
// typed correctly, reported as "wrong username or password".
|
||||
SetArrayLength(PwLines, 1);
|
||||
PwLines[0] := DbCredsPage.Values[1];
|
||||
SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False);
|
||||
end
|
||||
else
|
||||
PwFile := '';
|
||||
|
||||
Reference in New Issue
Block a user