Public Access
Compare commits
10
Commits
cfa83d91d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698c102c1b | ||
|
|
c3ad14cb88 | ||
|
|
1965e07c7c | ||
|
|
b34360c917 | ||
|
|
1edd6520a3 | ||
|
|
1fa89da183 | ||
|
|
585befe922 | ||
|
|
0747612810 | ||
|
|
d499721132 | ||
|
|
e384719ecb |
+121
-3
@@ -323,6 +323,121 @@ function Add-Practices($entityName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-PracticeNames($entityName) {
|
||||||
|
$export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1"
|
||||||
|
if (-not (Test-Path $export1)) { return @() }
|
||||||
|
return @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name })
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same rules engine.ps1 uses: practice.json replaces or adds to entity.json for
|
||||||
|
# that one practice, so what gets tested here is what will actually be sent.
|
||||||
|
function Get-MergedPracticeConfig($entityName, $practiceName, $entityConfig) {
|
||||||
|
$file = Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName) "practice.json"
|
||||||
|
$merged = $entityConfig | ConvertTo-Json -Depth 10 | ConvertFrom-Json
|
||||||
|
if (-not (Test-Path $file)) { return $merged }
|
||||||
|
|
||||||
|
$override = Get-Content $file -Raw | ConvertFrom-Json
|
||||||
|
foreach ($section in $override.PSObject.Properties) {
|
||||||
|
if ($section.Name -like "_*") { continue }
|
||||||
|
if ($null -eq $merged.PSObject.Properties[$section.Name]) {
|
||||||
|
$merged | Add-Member -NotePropertyName $section.Name -NotePropertyValue $section.Value
|
||||||
|
} elseif ($section.Value -is [System.Management.Automation.PSCustomObject]) {
|
||||||
|
foreach ($prop in $section.Value.PSObject.Properties) {
|
||||||
|
$merged.$($section.Name) | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Set-Field $merged $section.Name $section.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-PracticeLogin($entityName, $entityConfig) {
|
||||||
|
$practices = Get-PracticeNames $entityName
|
||||||
|
if ($practices.Count -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "No practice folders yet - add some first." -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$choice = Read-Choice "Which practice?" $practices 1
|
||||||
|
$practiceName = $practices[$choice - 1]
|
||||||
|
$dir = Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName
|
||||||
|
$file = Join-Path $dir "practice.json"
|
||||||
|
|
||||||
|
$existing = $null
|
||||||
|
if (Test-Path $file) { $existing = Get-Content $file -Raw | ConvertFrom-Json }
|
||||||
|
|
||||||
|
Write-Section "$practiceName"
|
||||||
|
Write-Host "Anything you set here is used for $practiceName only." -ForegroundColor DarkGray
|
||||||
|
Write-Host "Anything you leave alone still comes from $entityName." -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
$ftps = [ordered]@{}
|
||||||
|
if ($existing -and $existing.ftps) {
|
||||||
|
foreach ($p in $existing.ftps.PSObject.Properties) { $ftps[$p.Name] = $p.Value }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if (Read-YesNo "Does $practiceName have its own username and password?" "y") {
|
||||||
|
Write-Host ""
|
||||||
|
$ftps["username"] = Read-Answer "Username for $practiceName" $ftps["username"]
|
||||||
|
$keep = $false
|
||||||
|
if ($ftps["password"]) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "A password is already saved for $practiceName." -ForegroundColor DarkGray
|
||||||
|
$keep = Read-YesNo "Keep it?" "y"
|
||||||
|
}
|
||||||
|
if (-not $keep) { $ftps["password"] = Read-Answer "Password for $practiceName" $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if (Read-YesNo "Does $practiceName use a different server than the rest of ${entityName}?" "n") {
|
||||||
|
$srv = Read-ServerDetails "$practiceName" $existing.ftps
|
||||||
|
$ftps["host"] = $srv.host
|
||||||
|
$ftps["protocol"] = $srv.protocol
|
||||||
|
$ftps["username"] = $srv.username
|
||||||
|
$ftps["password"] = $srv.password
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if (Read-YesNo "Does $practiceName use a different folder on the server?" "n") {
|
||||||
|
$ftps["tiff_path"] = Read-RemotePath "$practiceName's daily image files" $ftps["tiff_path"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ftps.Count -eq 0) {
|
||||||
|
if (Test-Path $file) {
|
||||||
|
Remove-Item $file -Force
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Nothing set - removed $practiceName's practice.json." -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Nothing set for $practiceName." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [ordered]@{
|
||||||
|
"_note" = "Settings for $practiceName only. Anything not listed comes from ..\..\entity.json."
|
||||||
|
ftps = [PSCustomObject]$ftps
|
||||||
|
}
|
||||||
|
([PSCustomObject]$out) | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Saved $file" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-PracticeLogins($entityName, $entityConfig) {
|
||||||
|
$practices = Get-PracticeNames $entityName
|
||||||
|
if ($practices.Count -eq 0) { return }
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Practices" -ForegroundColor White
|
||||||
|
foreach ($p in $practices) {
|
||||||
|
$merged = Get-MergedPracticeConfig $entityName $p $entityConfig
|
||||||
|
$own = Test-Path (Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $p) "practice.json")
|
||||||
|
$tag = if ($own) { "own settings" } else { "uses $entityName's" }
|
||||||
|
Write-Host (" {0,-12} {1,-18} login: {2}" -f $p, $tag, (Show-Value $merged.ftps.username))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Edit-Entity($entityName) {
|
function Edit-Entity($entityName) {
|
||||||
$config = Get-EntityConfig $entityName
|
$config = Get-EntityConfig $entityName
|
||||||
if (-not $config) {
|
if (-not $config) {
|
||||||
@@ -340,15 +455,17 @@ function Edit-Entity($entityName) {
|
|||||||
while ($true) {
|
while ($true) {
|
||||||
Write-Header "$entityName - current settings"
|
Write-Header "$entityName - current settings"
|
||||||
Show-Settings $config
|
Show-Settings $config
|
||||||
|
Show-PracticeLogins $entityName $config
|
||||||
|
|
||||||
$choice = Read-Choice "What do you want to change?" @(
|
$choice = Read-Choice "What do you want to change?" @(
|
||||||
"The daily-upload server (address, protocol, login)",
|
"The daily-upload server (address, protocol, login)",
|
||||||
"Where the daily image files go on the server",
|
"Where the daily image files go on the server",
|
||||||
"Where PT STMT files from the dated folder go",
|
"Where PT STMT files from the dated folder go",
|
||||||
"Patient files (server and folder)",
|
"Patient files (server and folder)",
|
||||||
|
"Give one practice its own login or folder",
|
||||||
"Add practice folders",
|
"Add practice folders",
|
||||||
"Nothing - save and go back"
|
"Nothing - save and go back"
|
||||||
) 6
|
) 7
|
||||||
|
|
||||||
switch ($choice) {
|
switch ($choice) {
|
||||||
1 {
|
1 {
|
||||||
@@ -396,8 +513,9 @@ function Edit-Entity($entityName) {
|
|||||||
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
|
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5 { Add-Practices $entityName }
|
5 { Set-PracticeLogin $entityName $config }
|
||||||
6 {
|
6 { Add-Practices $entityName }
|
||||||
|
7 {
|
||||||
$file = Save-EntityConfig $entityName $config
|
$file = Save-EntityConfig $entityName $config
|
||||||
Copy-RunBat $entityName
|
Copy-RunBat $entityName
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
+466
@@ -0,0 +1,466 @@
|
|||||||
|
# Standalone SFTP/FTP login test. Nothing is read from a config file - you type
|
||||||
|
# the details in - so it isolates the account from whatever a script is doing
|
||||||
|
# with it. Called by test-login.bat.
|
||||||
|
|
||||||
|
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||||
|
|
||||||
|
function Read-Answer($question, $default) {
|
||||||
|
while ($true) {
|
||||||
|
if ($default) {
|
||||||
|
Write-Host "$question " -NoNewline -ForegroundColor White
|
||||||
|
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
|
||||||
|
Write-Host ": " -NoNewline -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host "${question}: " -NoNewline -ForegroundColor White
|
||||||
|
}
|
||||||
|
$answer = Read-Host
|
||||||
|
if ([string]::IsNullOrWhiteSpace($answer)) {
|
||||||
|
if ($default) { return $default }
|
||||||
|
Write-Host " (required)" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return $answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
Write-Host " Login test" -ForegroundColor Cyan
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Type the details exactly as they work in WinSCP or FileZilla." -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Find WinSCPnet.dll wherever it happens to live - this script is meant to be
|
||||||
|
# dropped next to whatever uploader is in use, not just this one.
|
||||||
|
function Find-WinSCPDll {
|
||||||
|
$parent = Split-Path -Parent $scriptRoot
|
||||||
|
$candidates = @(
|
||||||
|
(Join-Path $scriptRoot "WinSCP\WinSCPnet.dll"),
|
||||||
|
(Join-Path $scriptRoot "WinSCPnet.dll"),
|
||||||
|
(Join-Path $parent "WinSCP\WinSCPnet.dll"),
|
||||||
|
(Join-Path $parent "WinSCPnet.dll"),
|
||||||
|
"C:\Program Files (x86)\WinSCP\WinSCPnet.dll",
|
||||||
|
"C:\Program Files\WinSCP\WinSCPnet.dll"
|
||||||
|
)
|
||||||
|
foreach ($c in $candidates) {
|
||||||
|
if (Test-Path $c) { return $c }
|
||||||
|
}
|
||||||
|
# Anywhere under the folder this script sits in.
|
||||||
|
$found = Get-ChildItem -Path $scriptRoot -Filter "WinSCPnet.dll" -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($found) { return $found.FullName }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$winscpDll = Find-WinSCPDll
|
||||||
|
if (-not $winscpDll) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Could not find WinSCPnet.dll automatically." -ForegroundColor Yellow
|
||||||
|
Write-Host "It sits either in a WinSCP folder next to your upload script, or" -ForegroundColor Gray
|
||||||
|
Write-Host "in C:\Program Files (x86)\WinSCP\ if WinSCP is installed normally." -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
$winscpDll = Read-Answer "Full path to WinSCPnet.dll" $null
|
||||||
|
if (-not (Test-Path $winscpDll)) {
|
||||||
|
Write-Host "Nothing at that path." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Using $winscpDll" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# Windows tags files copied from elsewhere as untrusted, which stops Add-Type
|
||||||
|
# loading them. Harmless everywhere else, so failure here is not worth a word.
|
||||||
|
try {
|
||||||
|
Get-ChildItem -Path (Split-Path -Parent $winscpDll) -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
|
Unblock-File -ErrorAction SilentlyContinue
|
||||||
|
} catch { }
|
||||||
|
try {
|
||||||
|
Add-Type -Path $winscpDll
|
||||||
|
} catch {
|
||||||
|
Write-Host "Could not load WinSCPnet.dll - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "If it says the operation is not supported, Windows has the file" -ForegroundColor Yellow
|
||||||
|
Write-Host "blocked: right-click it, Properties, tick Unblock, OK." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Number($question, $min, $max, $default) {
|
||||||
|
while ($true) {
|
||||||
|
Write-Host "$question " -NoNewline -ForegroundColor White
|
||||||
|
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
|
||||||
|
Write-Host ": " -NoNewline -ForegroundColor White
|
||||||
|
$a = Read-Host
|
||||||
|
if ([string]::IsNullOrWhiteSpace($a)) { $a = [string]$default }
|
||||||
|
$n = 0
|
||||||
|
if ([int]::TryParse(([string]$a).Trim(), [ref]$n) -and $n -ge $min -and $n -le $max) { return $n }
|
||||||
|
Write-Host " (a number between $min and $max)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# FileZilla keeps site passwords Base64-encoded, so the value sitting in the
|
||||||
|
# exported XML is not the password - it has to be decoded first. Copying it
|
||||||
|
# out verbatim fails against the server every time, which is exactly what a
|
||||||
|
# working FileZilla login plus a failing script looks like.
|
||||||
|
function Import-FileZillaSite {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "In FileZilla: File > Export, tick 'Site Manager entries', save the .xml." -ForegroundColor Gray
|
||||||
|
$path = Read-Answer "Path to the exported .xml" $null
|
||||||
|
$path = $path.Trim('"').Trim()
|
||||||
|
if (-not (Test-Path $path)) {
|
||||||
|
Write-Host " Nothing at that path." -ForegroundColor Red
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = [xml](Get-Content $path -Raw)
|
||||||
|
} catch {
|
||||||
|
Write-Host " That file is not readable XML - $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$servers = @($xml.SelectNodes("//Server"))
|
||||||
|
if ($servers.Count -eq 0) {
|
||||||
|
Write-Host " No sites found in that file." -ForegroundColor Red
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = $servers[0]
|
||||||
|
if ($servers.Count -gt 1) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Sites in that file:" -ForegroundColor White
|
||||||
|
for ($i = 0; $i -lt $servers.Count; $i++) {
|
||||||
|
$n = $servers[$i]
|
||||||
|
Write-Host (" {0}. {1} {2}@{3}:{4}" -f ($i + 1), $n.Name, $n.User, $n.Host, $n.Port)
|
||||||
|
}
|
||||||
|
$node = $servers[(Read-Number "Which one" 1 $servers.Count 1) - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
$pass = $null
|
||||||
|
$passNode = $node.SelectSingleNode("Pass")
|
||||||
|
if ($passNode) {
|
||||||
|
$encoding = $passNode.GetAttribute("encoding")
|
||||||
|
if ($encoding -eq "base64") {
|
||||||
|
try {
|
||||||
|
$pass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($passNode.InnerText))
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Decoded the password from Base64." -ForegroundColor Green
|
||||||
|
Write-Host " The XML holds $($passNode.InnerText.Length) characters; the real" -ForegroundColor Gray
|
||||||
|
Write-Host " password is $($pass.Length). Using the decoded one." -ForegroundColor Gray
|
||||||
|
} catch {
|
||||||
|
Write-Host " The password in the XML is not valid Base64." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} elseif ($encoding -eq "crypt") {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " This export is protected by a FileZilla master password," -ForegroundColor Yellow
|
||||||
|
Write-Host " so the password cannot be read out of it. Type it instead." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
$pass = $passNode.InnerText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# FileZilla's protocol numbering: 1 is SFTP, 0 is plain FTP, and the
|
||||||
|
# TLS variants use higher values. Only used as the menu default.
|
||||||
|
$proto = 1
|
||||||
|
switch ([string]$node.Protocol) {
|
||||||
|
"0" { $proto = 3 }
|
||||||
|
"1" { $proto = 1 }
|
||||||
|
"3" { $proto = 2 }
|
||||||
|
"4" { $proto = 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Host = [string]$node.Host
|
||||||
|
Port = [string]$node.Port
|
||||||
|
User = [string]$node.User
|
||||||
|
Password = $pass
|
||||||
|
Proto = $proto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromXml = $null
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Where are the details coming from?" -ForegroundColor White
|
||||||
|
Write-Host " 1. I will type them in"
|
||||||
|
Write-Host " 2. Read them from a FileZilla site export (.xml)"
|
||||||
|
if ((Read-Number "Choose 1-2" 1 2 1) -eq 2) {
|
||||||
|
$fromXml = Import-FileZillaSite
|
||||||
|
if (-not $fromXml) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Falling back to typing them in." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
$serverHost = Read-Answer "Server" $(if ($fromXml) { $fromXml.Host } else { $null })
|
||||||
|
$serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim()
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Protocol:" -ForegroundColor White
|
||||||
|
Write-Host " 1. SFTP"
|
||||||
|
Write-Host " 2. FTPS"
|
||||||
|
Write-Host " 3. FTP"
|
||||||
|
$protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 }
|
||||||
|
$protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault
|
||||||
|
$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1]
|
||||||
|
|
||||||
|
$defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port }
|
||||||
|
elseif ($protocol -eq "sftp") { 22 } else { 21 }
|
||||||
|
$port = Read-Number "Port" 1 65535 $defaultPort
|
||||||
|
$username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null })
|
||||||
|
|
||||||
|
if ($fromXml -and $fromXml.Password) {
|
||||||
|
$password = $fromXml.Password
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray
|
||||||
|
Write-Host "Password: " -NoNewline -ForegroundColor White
|
||||||
|
$secure = Read-Host -AsSecureString
|
||||||
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||||
|
$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||||
|
|
||||||
|
# Nothing echoes while typing, so a slipped paste looks identical to a good
|
||||||
|
# one. Typing it twice is the only way to be sure of what actually went in.
|
||||||
|
Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White
|
||||||
|
$secure2 = Read-Host -AsSecureString
|
||||||
|
$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2)
|
||||||
|
$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2)
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2)
|
||||||
|
|
||||||
|
if ($password -ne $password2) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " !! The two did not match." -ForegroundColor Red
|
||||||
|
Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red
|
||||||
|
Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red
|
||||||
|
Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- What the credentials actually contain ------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
Write-Host "What was entered" -ForegroundColor Cyan
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
Write-Host " Server : [$serverHost] port $port over $($protocol.ToUpper())"
|
||||||
|
Write-Host " Username : [$username] ($($username.Length) characters)"
|
||||||
|
Write-Host " Password : $($password.Length) characters"
|
||||||
|
|
||||||
|
$warned = $false
|
||||||
|
if ($username -ne $username.Trim()) {
|
||||||
|
Write-Host " !! The username has a leading or trailing space." -ForegroundColor Yellow
|
||||||
|
$warned = $true
|
||||||
|
}
|
||||||
|
if ($password -ne $password.Trim()) {
|
||||||
|
Write-Host " !! The password has a leading or trailing space." -ForegroundColor Yellow
|
||||||
|
$warned = $true
|
||||||
|
}
|
||||||
|
if ($password -match '[\$`]') {
|
||||||
|
Write-Host " !! The password contains a `$ or a backtick." -ForegroundColor Yellow
|
||||||
|
Write-Host " In a PowerShell script these MUST be in single quotes:" -ForegroundColor Yellow
|
||||||
|
Write-Host " `$Password = 'the`$password' <- correct" -ForegroundColor Yellow
|
||||||
|
Write-Host " `$Password = `"the`$password`" <- WRONG, PowerShell eats `$password" -ForegroundColor Yellow
|
||||||
|
Write-Host " This is the most common reason a login works in the WinSCP" -ForegroundColor Yellow
|
||||||
|
Write-Host " window but fails from a script with the same credentials." -ForegroundColor Yellow
|
||||||
|
$warned = $true
|
||||||
|
}
|
||||||
|
if ($password -match '[^\x20-\x7E]') {
|
||||||
|
Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow
|
||||||
|
$warned = $true
|
||||||
|
}
|
||||||
|
# A password copied straight out of a FileZilla export is still Base64.
|
||||||
|
if (-not $fromXml -and $password.Length -ge 8 -and $password -cmatch '^[A-Za-z0-9+/]+={0,2}$' -and ($password.Length % 4) -eq 0) {
|
||||||
|
$decoded = $null
|
||||||
|
try { $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($password)) } catch { }
|
||||||
|
if ($decoded -and $decoded -match '^[\x20-\x7E]+$') {
|
||||||
|
Write-Host " !! This looks like Base64, not a password." -ForegroundColor Yellow
|
||||||
|
Write-Host " FileZilla stores passwords Base64-encoded in its exported" -ForegroundColor Yellow
|
||||||
|
Write-Host " XML. Decoded, this is $($decoded.Length) characters - that is" -ForegroundColor Yellow
|
||||||
|
Write-Host " probably the real password. Re-run and choose option 2 to" -ForegroundColor Yellow
|
||||||
|
Write-Host " read the export directly, or paste the decoded value." -ForegroundColor Yellow
|
||||||
|
$warned = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $warned) {
|
||||||
|
Write-Host " Nothing unusual about either string." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Try it -------------------------------------------------------------------
|
||||||
|
$logFile = Join-Path $scriptRoot "login-test.log"
|
||||||
|
if (Test-Path $logFile) { Remove-Item $logFile -Force }
|
||||||
|
|
||||||
|
$opts = New-Object WinSCP.SessionOptions
|
||||||
|
$opts.HostName = $serverHost
|
||||||
|
$opts.PortNumber = $port
|
||||||
|
$opts.UserName = $username
|
||||||
|
$opts.Password = $password
|
||||||
|
if ($protocol -eq "sftp") {
|
||||||
|
$opts.Protocol = [WinSCP.Protocol]::Sftp
|
||||||
|
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
|
||||||
|
} else {
|
||||||
|
$opts.Protocol = [WinSCP.Protocol]::Ftp
|
||||||
|
if ($protocol -eq "ftps") {
|
||||||
|
$opts.FtpSecure = [WinSCP.FtpSecure]::Explicit
|
||||||
|
$opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true
|
||||||
|
} else {
|
||||||
|
$opts.FtpSecure = [WinSCP.FtpSecure]::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
Write-Host "Connecting" -ForegroundColor Cyan
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
|
||||||
|
$session = New-Object WinSCP.Session
|
||||||
|
$session.SessionLogPath = $logFile
|
||||||
|
$connected = $false
|
||||||
|
try {
|
||||||
|
$session.Open($opts)
|
||||||
|
$connected = $true
|
||||||
|
Write-Host " CONNECTED" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " FAILED" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connected) {
|
||||||
|
try {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Home directory: $($session.HomePath)" -ForegroundColor Gray
|
||||||
|
Write-Host " Folders visible at the top level:" -ForegroundColor Gray
|
||||||
|
$listing = $session.ListDirectory($session.HomePath)
|
||||||
|
$shown = 0
|
||||||
|
foreach ($item in $listing.Files) {
|
||||||
|
if ($item.Name -eq "." -or $item.Name -eq "..") { continue }
|
||||||
|
$kind = if ($item.IsDirectory) { "[dir] " } else { " " }
|
||||||
|
Write-Host " $kind$($item.Name)"
|
||||||
|
$shown++
|
||||||
|
if ($shown -ge 25) { Write-Host " ... (more)"; break }
|
||||||
|
}
|
||||||
|
if ($shown -eq 0) { Write-Host " (nothing - the account may be locked to an empty folder)" }
|
||||||
|
} catch {
|
||||||
|
Write-Host " Connected, but could not list the folder: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
$checkPath = Read-Answer "Remote folder to check (or . to skip)" "."
|
||||||
|
if ($checkPath -ne ".") {
|
||||||
|
try {
|
||||||
|
if ($session.FileExists($checkPath)) {
|
||||||
|
Write-Host " $checkPath ... found" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " $checkPath ... NOT THERE" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " Could not check: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$session.Dispose()
|
||||||
|
|
||||||
|
# -- The authentication conversation ------------------------------------------
|
||||||
|
if (-not $connected -and (Test-Path $logFile)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
Write-Host "What the server actually said" -ForegroundColor Cyan
|
||||||
|
Write-Host ("-" * 64)
|
||||||
|
|
||||||
|
$log = Get-Content $logFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$server = $log | Select-String -Pattern "Remote version:" | Select-Object -First 1
|
||||||
|
if ($server) { Write-Host " $(($server.Line -split 'Remote version:')[1].Trim())" -ForegroundColor Gray }
|
||||||
|
|
||||||
|
$methodLine = $log | Select-String -Pattern "Server offered these authentication methods:" | Select-Object -First 1
|
||||||
|
$methods = ""
|
||||||
|
if ($methodLine) {
|
||||||
|
$methods = (($methodLine.Line -split "authentication methods:")[1]).Trim()
|
||||||
|
Write-Host " Methods the server accepts: $methods" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentPassword = ($log | Select-String -Pattern "Sent password").Count -gt 0
|
||||||
|
$pwRejected = ($log | Select-String -Pattern "Password authentication failed").Count -gt 0
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($pwRejected -or ($sentPassword -and -not $connected)) {
|
||||||
|
Write-Host " The password reached the server and the server rejected it." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " That rules out the script, the protocol, the port and the host" -ForegroundColor Yellow
|
||||||
|
Write-Host " key - all of those worked. Only three things are left:" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 1. The password is not the one this account expects." -ForegroundColor White
|
||||||
|
Write-Host " Check it is this practice's own password, not another's." -ForegroundColor Gray
|
||||||
|
Write-Host " 2. The account is locked out from repeated failed tries," -ForegroundColor White
|
||||||
|
Write-Host " or expired. Log in with the WinSCP window right now - if" -ForegroundColor Gray
|
||||||
|
Write-Host " that fails too, it is the account, not anything here." -ForegroundColor Gray
|
||||||
|
Write-Host " 3. The account is set to require a key rather than a" -ForegroundColor White
|
||||||
|
Write-Host " password, even though the server allows passwords" -ForegroundColor Gray
|
||||||
|
Write-Host " generally. Only the partner can tell you that." -ForegroundColor Gray
|
||||||
|
} elseif ($methods -and $methods -notmatch "password") {
|
||||||
|
Write-Host " The server never offered password authentication." -ForegroundColor Yellow
|
||||||
|
Write-Host " It accepts: $methods" -ForegroundColor Yellow
|
||||||
|
Write-Host " A password will never work here - the partner has to enable" -ForegroundColor Yellow
|
||||||
|
Write-Host " password auth or issue an SSH key for this account." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host " It failed before any password was sent - so this is about" -ForegroundColor Yellow
|
||||||
|
Write-Host " reaching the server, not about the credentials." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Full log: $logFile" -ForegroundColor Cyan
|
||||||
|
Write-Host " It contains no password. Send it to Brad." -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
if ($connected) {
|
||||||
|
Write-Host " Login works. These details are correct." -ForegroundColor Green
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Getting a working password into a script is its own trap, so hand back
|
||||||
|
# the literal to paste rather than leaving the quoting to chance.
|
||||||
|
$needsCare = $password -match '[\$`]'
|
||||||
|
if ($needsCare -or $password -match "'" -or $password -match '%') {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Putting this password into a PowerShell script" -ForegroundColor Cyan
|
||||||
|
Write-Host (" " + ("-" * 60)) -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Use exactly this line, single quotes included:" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " `$Password = $("'" + ($password -replace "'", "''") + "'")" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
if ($needsCare) {
|
||||||
|
Write-Host " This password contains a `$ or a backtick. Inside DOUBLE" -ForegroundColor Yellow
|
||||||
|
Write-Host " quotes PowerShell treats that as a variable and silently" -ForegroundColor Yellow
|
||||||
|
Write-Host " sends something shorter - the login then fails even though" -ForegroundColor Yellow
|
||||||
|
Write-Host " the password is right. Single quotes send it untouched." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
if ($password -match "'") {
|
||||||
|
Write-Host " The password contains an apostrophe, so it is doubled in" -ForegroundColor Yellow
|
||||||
|
Write-Host " the line above. That is correct - it stays one character." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
if ($password -match '%') {
|
||||||
|
Write-Host " The password contains a %. That is fine in PowerShell, but" -ForegroundColor Yellow
|
||||||
|
Write-Host " in a .bat file it must be written as %% instead." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Safer still: keep it out of the script. Put the password on" -ForegroundColor Gray
|
||||||
|
Write-Host " the first line of its own file and read it back - then no" -ForegroundColor Gray
|
||||||
|
Write-Host " quoting rules apply to it at all:" -ForegroundColor Gray
|
||||||
|
Write-Host ' $Password = Get-Content pw.txt -TotalCount 1' -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Nothing in this password needs special quoting in a script." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
Write-Host " If a script using these same details still fails, the script is" -ForegroundColor Green
|
||||||
|
Write-Host " changing one of them on the way through." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Login failed. Compare against the WinSCP window: File > Session" -ForegroundColor Yellow
|
||||||
|
Write-Host " > Generate Session URL/Code > .NET assembly code. That shows the" -ForegroundColor Yellow
|
||||||
|
Write-Host " exact settings the working connection uses." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
490c308 2026-08-26 setup wizard + any-file-type upload
|
c3ad14c 2026-08-30 password quoting guidance
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit checks for the PowerShell scripts. Run from the repo root.
|
||||||
|
# Needs pwsh on PATH (see the project notes for how it was installed).
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
echo "== ASCII only =="
|
||||||
|
# Windows PowerShell 5.1 reads a BOM-less .ps1 in the system codepage, which
|
||||||
|
# turns any multi-byte character into mojibake - including quote characters
|
||||||
|
# that break string literals mid-script.
|
||||||
|
for f in *.ps1; do
|
||||||
|
if LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" >/dev/null; then
|
||||||
|
echo " FAIL $f contains non-ASCII:"
|
||||||
|
LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" | head -5
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " ok $f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== \$var? in a string =="
|
||||||
|
# ? is a legal character in an unbraced PowerShell variable name, so "$name?"
|
||||||
|
# looks up a variable called name? and silently renders empty. Use ${name}?.
|
||||||
|
if grep -nP '\$\w+\?' ./*.ps1; then
|
||||||
|
echo " FAIL - wrap those as \${name}? or the text disappears at runtime"
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " ok none found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== parse + Windows PowerShell 5.1 compatibility =="
|
||||||
|
pwsh -NoProfile -Command '
|
||||||
|
$bad = 0
|
||||||
|
foreach ($f in Get-ChildItem -Filter *.ps1) {
|
||||||
|
$e = $null
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e) | Out-Null
|
||||||
|
if ($e.Count) {
|
||||||
|
$bad++
|
||||||
|
Write-Host " FAIL $($f.Name)"
|
||||||
|
$e | ForEach-Object { " line $($_.Extent.StartLineNumber): $($_.Message)" }
|
||||||
|
} else { Write-Host " ok $($f.Name)" }
|
||||||
|
}
|
||||||
|
if (Get-Module -ListAvailable PSScriptAnalyzer) {
|
||||||
|
Import-Module PSScriptAnalyzer
|
||||||
|
$s = @{ Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @("5.1","7.0") } } }
|
||||||
|
$c = Invoke-ScriptAnalyzer -Path . -Recurse -Settings $s -IncludeRule PSUseCompatibleSyntax
|
||||||
|
if ($c) { $bad++; $c | ForEach-Object { " FAIL 5.1: $($_.ScriptName):$($_.Line) $($_.Message)" } }
|
||||||
|
else { Write-Host " ok 5.1 syntax clean" }
|
||||||
|
} else {
|
||||||
|
Write-Host " skip PSScriptAnalyzer not installed"
|
||||||
|
}
|
||||||
|
exit $bad
|
||||||
|
' || fail=1
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== JSON templates =="
|
||||||
|
python3 - <<'PY' || fail=1
|
||||||
|
import glob, json, sys
|
||||||
|
bad = 0
|
||||||
|
for f in glob.glob("Entities/**/*.json", recursive=True) + ["settings.json"]:
|
||||||
|
try:
|
||||||
|
json.load(open(f))
|
||||||
|
print(" ok %s" % f)
|
||||||
|
except Exception as e:
|
||||||
|
print(" FAIL %s: %s" % (f, e)); bad = 1
|
||||||
|
sys.exit(bad)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED"; else echo "CHECKS FAILED"; fi
|
||||||
|
exit $fail
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
REM Tests one SFTP/FTP login on its own. Nothing is read from a config file.
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File "%~dp0Test-Login.ps1"
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user