Private/Kinds/Nextcloud.ps1
|
# The Nextcloud Kind: whether the Nextcloud client is keeping up, read from its own journal. # # The counterpart of the OneDrive Kind for the other sync client a Customer runs. The # Nextcloud client keeps, in every folder it synchronises, a journal: an SQLite file # (.sync_<id>.db) with a row for every file it knows and, apart from those, the files it # could not transfer and has set aside, the conflicts nobody has resolved, and the # transfers that are under way. Observed 2026-10-13 on a workstation with one account # and one folder: 19621 files, 11534 of them only in the cloud, 5682 folders, nothing set # aside and no conflict. # # What is read: # # - nextcloud.cfg, the client's configuration: the version, each account's server, and # each folder with where it lies, whether it is paused and whether it keeps files # only in the cloud. Of a server its name and its address are kept, without a user # name or a password the address may carry; no user name and no certificate. # - The journal of each folder: the counts, and the files set aside by what the client # wrote as the reason - never by their path. Which file it is, is what a User keeps # there, and a Run reads state and not content. See ADR-0006. A client up to 3.16 # wrote the URL of the request into the reason: it is said in full, as the client # wrote it (#176). # - Each file set aside once more, as when the client last tried it and why, for the # Timeline (ADR-0010): on the account's server, on the port its address names. The # journal keeps one row per file and drops it once the file syncs, so these are the # files still failing and not a history of what failed. # - Of each account whether it can sign in (#185): how it signs in (authType) and whether # a user is set, from nextcloud.cfg, and whether a credential is saved for it, by the # target names cmdkey /list prints and never by the secret (ADR-0016). The client # saves it under Nextcloud_<user>:<url>:<n>, and the app password of a web sign-in # under Nextcloud_<id>_app-password:<url>:<n>, n the account's number in # nextcloud.cfg (docs/research/nextcloud-sign-in.md). A target is matched to its # account by the address and the number, so the user is compared with nothing. # - Each account's server, asked without credentials (#179): status.php, whether it is in # maintenance or waits for its database to be upgraded, and its version; and the # schemes the WWW-Authenticate of an anonymous request to remote.php/dav/ offers # (Basic, Bearer, Negotiate). Kept on each account as Schemes, the names alone, for # whatever asks whether Windows sign-in could work. Measured as the HttpsEndpoint Kind # measures a request, on the account's Service. # # The credential store, nextcloud.cfg and the journal are the User's: read from an elevated # Run, or from one that runs as another account (RunUser.Differs), they are somebody # else's, and the account's Finding says it could not check rather than that all is well. # # The client holds its journal locked against every other reader, so it is read as a # file nobody writes to (Private\Sqlite.ps1). That shows the journal as of its last # change on disk, and misses what the client has noted since and not yet written back: # on the machine observed that was ten hours. A Finding says how old what it read is. # The kinds of entry in the journal's list of files, as the client numbers them. $script:NextcloudTypeFile = 0 $script:NextcloudTypeFolder = 2 $script:NextcloudTypePlaceholder = 4 # What the Judge observes of how things are connected (ADR-0012): each account's server a # Service by its address. The Run adds that the App uses it. $script:NextcloudLinks = @('OnServer:Service') # How many of the files set aside are read one by one, the newest first: enough for the # Timeline, and a journal of thousands of them does not become thousands of events. The # counts by reason are of them all. $script:NextcloudFailureLimit = 200 # How much of status.php's answer is kept: a JSON object of a few hundred bytes. $script:NextcloudStatusBytes = 4096 function ConvertFrom-NextcloudJournalTime { <# .SYNOPSIS A time as the journal keeps it, seconds since 1970 in UTC, as local time. Nothing for none. Pure. #> [CmdletBinding()] param([AllowNull()]$Seconds) $number = ConvertTo-Number $Seconds if ($null -eq $number -or $number -le 0) { return $null } ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds($number).ToLocalTime() } function ConvertFrom-NextcloudConfig { <# .SYNOPSIS The text of nextcloud.cfg as the client's version, its accounts and its folders. Pure. .DESCRIPTION The file is an ini as Qt writes it. Under [Accounts] a key is a path: "0\url" is the server of account 0, and "0\FoldersWithPlaceholders\1\localPath" is where folder 1 of that account lies. A folder stands under "Folders", "FoldersWithPlaceholders" or "Multifolders", by how it was set up. Read as any ini file is (ConvertFrom-IniText), as the AppLog Kind reads the account's server out of it for the client's own log. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $version = '' $servers = @{} $urls = @{} $folders = [ordered]@{} # Of each account how it signs in and whether a user is set - never who the user is: a # saved credential is matched by the address and the account's number # (docs/research/nextcloud-sign-in.md). $signIn = [ordered]@{} foreach ($entry in (ConvertFrom-IniText -Text $Text)) { $section = $entry.Section $name = $entry.Name $value = $entry.Value if ($section -eq 'General' -and $name -eq 'clientVersion') { $version = $value; continue } if ($section -ne 'Accounts') { continue } if ($name -match '^(\d+)\\(authType|dav_user|webflow_user|serverVersion)$') { if (-not $signIn.Contains($Matches[1])) { $signIn[$Matches[1]] = @{} } $signIn[$Matches[1]][$Matches[2]] = "$value".Trim() continue } if ($name -match '^(\d+)\\url$') { $account = $Matches[1] if (-not $signIn.Contains($account)) { $signIn[$account] = @{} } $server = $value $url = '' try { $uri = [uri]$value $server = $uri.Host # The address without a user or a password it may carry. $url = $uri.GetComponents([UriComponents]::SchemeAndServer -bor [UriComponents]::Path, [UriFormat]::UriEscaped) if (-not $uri.AbsolutePath.Trim('/') -and -not $url.EndsWith('/')) { $url += '/' } } catch { $server = $value } $servers[$account] = "$server" $urls[$account] = "$url" continue } if ($name -match '^(\d+)\\(?:Folders|FoldersWithPlaceholders|Multifolders)\\([^\\]+)\\(localPath|journalPath|paused|virtualFilesMode)$') { $key = '{0}|{1}' -f $Matches[1], $Matches[2] if (-not $folders.Contains($key)) { $folders[$key] = @{ Account = $Matches[1] } } $folders[$key][$Matches[3]] = $value } } [pscustomobject]@{ Version = $version Accounts = @($signIn.Keys | Where-Object { $urls.ContainsKey($_) -and $urls[$_] } | ForEach-Object { $one = $signIn[$_] [pscustomobject]@{ Account = "$_" Server = "$($servers[$_])" Url = "$($urls[$_])" AuthType = "$($one['authType'])" HasUser = [bool]("$($one['dav_user'])" -or "$($one['webflow_user'])") # The server's version as the client last saw it, when it connected. ServerVersion = "$($one['serverVersion'])" } }) Folders = @($folders.Keys | ForEach-Object { $one = $folders[$_] # Qt writes a path with forward slashes, and a backslash doubled. $path = ("$($one['localPath'])" -replace '\\\\', '\' -replace '/', '\').TrimEnd('\') if (-not $path) { return } [pscustomobject]@{ Account = "$($one['Account'])" Server = "$($servers[$one['Account']])" Url = "$($urls[$one['Account']])" Path = $path Journal = "$($one['journalPath'])" Paused = ("$($one['paused'])" -eq 'true') Virtual = ("$($one['virtualFilesMode'])" -and "$($one['virtualFilesMode'])" -ne 'off') } }) } } function Read-NextcloudJournal { <# .SYNOPSIS What the journal of one folder says: the counts, and the files set aside by the reason the client wrote. Asks only; reads no path of a file but the URL a reason names. Never throws. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Path) $read = [ordered]@{ Read = $false; Problem = ''; Files = $null; Folders = $null; Placeholders = $null Errors = @(); Failures = @(); Conflicts = $null; CaseConflicts = $null; Uploads = $null; Downloads = $null } try { $types = @{} foreach ($row in (Read-SqliteFile -Path $Path -Query 'select type, count(*) from metadata group by type')) { $types["$($row[0])"] = [int]$row[1] } $read.Files = [int]$types["$script:NextcloudTypeFile"] + [int]$types["$script:NextcloudTypePlaceholder"] $read.Placeholders = [int]$types["$script:NextcloudTypePlaceholder"] $read.Folders = [int]$types["$script:NextcloudTypeFolder"] $read.Read = $true } catch { $read.Problem = "$($_.Exception.GetBaseException().Message)"; return [pscustomobject]$read } # Each of these by itself: a journal of an older client lacks a table, and that is # no reason to lose the others. try { $read.Errors = @(Read-SqliteFile -Path $Path -Query 'select errorCategory, errorstring, count(*), max(lastTryTime) from blacklist group by errorCategory, errorstring order by 3 desc limit 20' | ForEach-Object { [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Count = [int]$_[2]; Last = ConvertFrom-NextcloudJournalTime $_[3] } }) } catch { $read.Errors = @() } # Each by its last attempt and its reason, and not by its path. try { $read.Failures = @(Read-SqliteFile -Path $Path -Query ('select errorCategory, errorstring, lastTryTime from blacklist order by lastTryTime desc limit {0}' -f $script:NextcloudFailureLimit) | ForEach-Object { [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Time = ConvertFrom-NextcloudJournalTime $_[2] } }) } catch { $read.Failures = @() } foreach ($pair in @(@('Conflicts', 'conflicts'), @('CaseConflicts', 'caseconflicts'), @('Uploads', 'uploadinfo'), @('Downloads', 'downloadinfo'))) { try { $read[$pair[0]] = [int]@(Read-SqliteFile -Path $Path -Query ('select count(*) from {0}' -f $pair[1]))[0][0] } catch { $read[$pair[0]] = $null } } [pscustomobject]$read } # What Get-NextcloudData hands its Judge. See Private/DataShape.ps1. $script:NextcloudDataShape = @{ App = 'Value' Configured = 'Answered' Version = 'Value' Accounts = 'List' Folders = 'List' Running = 'Value' SavedCredentials = 'List' SavedCredentialsError = 'Value' IsElevated = 'Answered' RunUser = 'Value' Prefixes = 'List' Routes = 'List' GatheredAt = 'Answered' } function Get-NextcloudData { <# .PARAMETER Observed What earlier Checks gathered. Only 'RunUser' is read: whose Session the Run is in. #> [CmdletBinding()] [OutputType([psobject])] param( [hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{} ) $configPath = [Environment]::ExpandEnvironmentVariables("$(Get-Parameter $Parameters 'ConfigPath' '%APPDATA%\Nextcloud\nextcloud.cfg')") $config = $null if (Test-Path -LiteralPath $configPath -PathType Leaf) { try { $config = ConvertFrom-NextcloudConfig -Text (Read-AppLogFile -Path $configPath) } catch { $config = $null } } $folders = @((Get-DataCollection $config 'Folders') | Where-Object { $_ } | ForEach-Object { $exists = [bool](Test-Path -LiteralPath $_.Path -PathType Container) $journal = $null $written = $null $file = $(if ($_.Journal) { Join-Path $_.Path $_.Journal } else { '' }) if ($exists -and $file -and (Test-Path -LiteralPath $file -PathType Leaf)) { $written = (Get-Item -LiteralPath $file -Force).LastWriteTime $journal = Read-NextcloudJournal -Path $file } [pscustomobject]@{ Account = $_.Account Server = $_.Server Url = $_.Url Path = $_.Path Exists = $exists Paused = $_.Paused Virtual = $_.Virtual # $null where there is no journal: not a journal that names no error. Journal = $journal JournalWritten = $written } }) $running = $null try { $running = [bool]@(Get-Process -Name 'nextcloud' -ErrorAction SilentlyContinue).Count } catch { $running = $null } # Each account's server asked, without credentials, what state it is in and how it wants # to be signed in to (#179). Once per address, however many accounts share it. $asked = @{} $accounts = @((Get-DataCollection $config 'Accounts') | Where-Object { $_ } | ForEach-Object { $url = "$($_.Url)" if (-not $asked.ContainsKey($url)) { $asked[$url] = Invoke-NextcloudServerProbe -Url $url } $answer = $asked[$url] $service = ConvertTo-UrlService -Url $url [pscustomobject]@{ Account = $_.Account Server = $_.Server Port = $(if ($service) { $service.Port } else { $null }) Url = $url AuthType = $_.AuthType HasUser = $_.HasUser ServerVersion = $_.ServerVersion AskedAt = $answer.AskedAt StatusProbe = $answer.StatusProbe DavProbe = $answer.DavProbe Schemes = $answer.Schemes } }) # What the Judge decides each server's Zone from, as the HttpsEndpoint Kind reads it. $addresses = @($asked.Values | ForEach-Object { "$($_.StatusProbe.RemoteAddress)" } | Where-Object { $_ } | Select-Object -Unique) # The user's own store, by target name: only the client's own targets leave this block, # every other entry is someone's unrelated account. Not asked where there is no account. $credentials = @() $credentialsError = $null if ($accounts.Count) { $saved = Read-CredentialTargetName $credentialsError = $saved.Error $credentials = @(@($saved.Targets) | Where-Object { "$_" -like 'Nextcloud_*' }) } # Whose Session the Run is in, as the Run decided it once for every Check about the # User. Absent where this Check is performed without a Run around it. $runUser = Get-ObservedRunUser -Observed $Observed if ($null -eq $runUser) { $runUser = ConvertTo-RunUserObserved -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LoggedOnUser (Get-InteractiveUser) } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Nextcloud' App = "$(Get-Parameter $Parameters 'App' 'Nextcloud')" Configured = $null -ne $config Version = "$(Get-DataProperty $config 'Version')" Accounts = $accounts Folders = $folders Running = $running # Target names only, the client's own. SavedCredentialsError says where the store # could not be listed: then none is not "none saved". SavedCredentials = $credentials SavedCredentialsError = $credentialsError IsElevated = (Get-CurrentPrivilege) -eq 'admin' RunUser = $runUser Prefixes = @(if ($addresses.Count) { Get-OwnPrefix }) Routes = @($addresses | ForEach-Object { Get-AddressRoute -Address $_ }) GatheredAt = Get-Date } } function Invoke-NextcloudServerProbe { <# .SYNOPSIS Asks one Nextcloud server, without credentials, what state it is in (status.php) and how it wants to be signed in to (the WWW-Authenticate of remote.php/dav): AskedAt, StatusProbe, DavProbe and Schemes, the names of the schemes it offered. Judges nothing, and never throws. .DESCRIPTION Through the HttpsEndpoint Kind's probe, which has no way to send a credential, a cookie or an Authorization header (ADR-0016). Neither request is a sign-in: the server answers status.php to anybody, and remote.php/dav without an Authorization header with its challenge alone. Nextcloud counts a failed sign-in towards its brute-force throttling only where a user and a password were tried (apps/dav/lib/Connector/Sabre/Auth.php, validateUserPass, reached from Sabre's AbstractBasic only with an "Authorization: Basic" header), so neither request slows down the User's own client. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Url) $base = "$Url".TrimEnd('/') + '/' $askedAt = Get-Date $status = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'Status'; Method = 'GET'; BearerProbe = $false; ReadBodyBytes = $script:NextcloudStatusBytes; Url = $base + 'status.php' }) $dav = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'Dav'; Method = 'GET'; BearerProbe = $false; Url = $base + 'remote.php/dav/' }) [pscustomobject]@{ AskedAt = $askedAt StatusProbe = $status DavProbe = $dav Schemes = [string[]]@(@($dav.WwwAuthenticate) | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } | ForEach-Object { "$($_.Scheme)" } | Where-Object { $_ } | Select-Object -Unique) } } function New-NextcloudSyncFinding { <# .SYNOPSIS Whether one folder is keeping up: the files the client set aside and why, the conflicts nobody resolved, what is under way, and how old the journal is. .DESCRIPTION A WARN where the client set files aside or a conflict is open: both stay until somebody does something. A transfer under way and a folder that is paused are said and are no fault. #> [CmdletBinding()] param([AllowNull()]$Folder, [AllowNull()]$Data, [hashtable]$Parameters) $path = "$(Get-DataProperty $Folder 'Path')" $check = (Get-Text 'Check.Nextcloud.Sync') -f $path if ((Get-DataProperty $Data 'Running') -eq $false) { return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.NotRunning') ` -Meaning (Get-Text 'Meaning.Nextcloud.NotRunning') -Hint (Get-Text 'Hint.Nextcloud.NotRunning') } if ((Get-DataProperty $Folder 'Exists') -eq $false) { return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.FolderMissing') ` -Meaning (Get-Text 'Meaning.Nextcloud.FolderMissing') -Hint (Get-Text 'Hint.Nextcloud.FolderMissing') } $journal = Get-DataProperty $Folder 'Journal' if ($null -eq $journal -or -not [bool](Get-DataProperty $journal 'Read')) { $value = Get-Text 'Value.Nextcloud.NoJournal' $problem = "$(Get-DataProperty $journal 'Problem')" if ($problem -eq 'NoSqlite') { $value = Get-Text 'Value.Nextcloud.NoSqlite' } return New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint (Get-Text 'Hint.Nextcloud.NoJournal') } $errors = @((Get-DataCollection $journal 'Errors') | Where-Object { $_ }) $failed = [int](($errors | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum) $conflicts = [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts')) $moving = [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads')) $paused = [bool](Get-DataProperty $Folder 'Paused') $value = @() if ($failed) { $reasons = @($errors | Select-Object -First 5 | ForEach-Object { $text = "$(Get-DataProperty $_ 'Text')" if (-not $text) { $text = Get-Text 'Value.Nextcloud.NoReason' } # The one category the client keeps apart: the server is full. if ((ConvertTo-Number (Get-DataProperty $_ 'Category')) -eq 1) { $text = (Get-Text 'Value.Nextcloud.ServerFull') -f $text } '{0} {1}x' -f ($text -replace ';', ','), [int](ConvertTo-Number (Get-DataProperty $_ 'Count')) }) $value += $(if ($failed -eq 1) { Get-Text 'Value.Nextcloud.Failed.One' } else { Get-Text 'Value.Nextcloud.Failed' $failed }) $value += (Get-Text 'Value.Nextcloud.Reasons') -f ($reasons -join '; ') } if ($conflicts) { $value += (Get-Text 'Value.Nextcloud.Conflicts') -f $conflicts } if ($moving) { $value += (Get-Text 'Value.Nextcloud.Moving') -f $moving } if ($paused) { $value += Get-Text 'Value.Nextcloud.Paused' } if (-not $failed -and -not $conflicts -and -not $moving -and -not $paused) { $value += Get-Text 'Value.Nextcloud.UpToDate' } $value += (Get-Text 'Value.Nextcloud.Counts') -f [int](ConvertTo-Number (Get-DataProperty $journal 'Files')), [int](ConvertTo-Number (Get-DataProperty $journal 'Folders')), [int](ConvertTo-Number (Get-DataProperty $journal 'Placeholders')) $written = ConvertTo-DataTime (Get-DataProperty $Folder 'JournalWritten') if ($null -ne $written) { $value += (Get-Text 'Value.Nextcloud.JournalAt') -f $written } $reference = @(New-Reference -Section 'Title.Nextcloud.Folders') if ($failed -or $conflicts) { $meaning = @() $hint = @() if ($failed) { $meaning += Get-Text 'Meaning.Nextcloud.Failed'; $hint += Get-Text 'Hint.Nextcloud.Failed' } if ($conflicts) { $meaning += Get-Text 'Meaning.Nextcloud.Conflicts'; $hint += Get-Text 'Hint.Nextcloud.Conflicts' } $newest = @($errors | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Last') } | Where-Object { $null -ne $_ } | Sort-Object)[-1] # The server refused the account for some of them, where the timeline draws it so. # ADR-0005. $signal = @(Get-TimedEventDeniedSignal -TimedEvent @(ConvertTo-NextcloudTimedEvent -Folder $Folder)) # A refusal is the Service's that refused, as the timeline draws it: the account's # host:port, which a Lead joins to the cause it finds on the same Service (#183). # Files set aside for anything else are the folder's, and about no Service. $about = @{} if ($signal -contains 'service-denied') { $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Folder 'Url')" $about.Subject = $(if ($service) { $service.Subject } else { Format-ServiceSubject -Server "$(Get-DataProperty $Folder 'Server')".Trim() -Port 443 }) } return New-Finding -Category Apps -Check $check -Severity WARN -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $reference -LastSeen $newest -Signal $signal @about } if ($moving -or $paused) { return New-Finding -Category Apps -Check $check -Severity INFO -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.Nextcloud.Waits') -Hint (Get-Text 'Hint.Nextcloud.Waits') -Reference $reference } New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ') -Reference $reference } function New-NextcloudSetupFinding { <# .SYNOPSIS How the Nextcloud client is set up for the User of the Run: the servers, the folders, whether files are kept only in the cloud, and the version. #> [CmdletBinding()] param([AllowNull()]$Data) $check = Get-Text 'Check.Nextcloud.Setup' $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ }) if (-not $folders.Count) { return New-Finding -Category Apps -Check $check -Severity INFO -Value (Get-Text 'Value.Nextcloud.NoFolder') -Hint (Get-Text 'Hint.Nextcloud.NoFolder') } $servers = @($folders | ForEach-Object { "$(Get-DataProperty $_ 'Server')" } | Where-Object { $_ } | Sort-Object -Unique) $value = @() if ($servers.Count) { $value += (Get-Text 'Value.Nextcloud.Servers') -f ($servers -join ', ') } $value += (Get-Text 'Value.Nextcloud.FolderList') -f (@($folders | ForEach-Object { $mode = Get-Text 'Value.Nextcloud.Mode.Full' if ([bool](Get-DataProperty $_ 'Virtual')) { $mode = Get-Text 'Value.Nextcloud.Mode.Virtual' } (Get-Text 'Value.Nextcloud.FolderItem') -f "$(Get-DataProperty $_ 'Path')", $mode }) -join '; ') $version = "$(Get-DataProperty $Data 'Version')".Trim() if ($version) { $value += (Get-Text 'Value.Nextcloud.Version') -f $version } New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ') } # A credential the client saved for an account, by its target name: Nextcloud_, the user or # the id of an app password, then the account's address and its number in nextcloud.cfg # (docs/research/nextcloud-sign-in.md). The address holds colons of its own, so the number # is the digits after the last one. $script:NextcloudCredentialPattern = '^Nextcloud_.*?:(?<url>https?://.+):(?<account>\d+)$' function Select-NextcloudCredentialTarget { <# .SYNOPSIS The target names of the credentials saved for one account: those that end in its address and its number. Pure. .DESCRIPTION Matched by the address and the number, and not by the user the name begins with, so that the user is compared with nothing. The address is compared without regard to case and to a trailing slash. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyCollection()][string[]]$Target, [AllowNull()][AllowEmptyString()][string]$Url, [AllowNull()][AllowEmptyString()][string]$Account ) $want = "$Url".Trim().TrimEnd('/') if (-not $want -or "$Account" -notmatch '^\d+$') { return } foreach ($name in @($Target | Where-Object { $_ })) { if ("$name" -notmatch $script:NextcloudCredentialPattern) { continue } if ([int]$Matches['account'] -ne [int]$Account) { continue } if ($Matches['url'].TrimEnd('/') -ne $want) { continue } "$name" } } function Get-NextcloudRefusal { <# .SYNOPSIS How often an account's server refused it and has not let it in since: the attempts drawn Denied after the newest one it did not refuse, with Count, Since and Last. Nothing where there is none. Pure. .DESCRIPTION The journal keeps no time of the last sync that went through: a file that syncs is dropped from it (ConvertTo-NextcloudTimedEvent). So each refusal it still holds is an attempt at a file that has not synced since. An attempt the server answered otherwise - a file locked, the server full - got past the sign-in, and the refusals before it are no longer the account's state: they are counted only where they are newer than every such attempt. Read off the events as the timeline draws them, so that the count is what the Service's lane shows. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Account, [AllowNull()]$Data) $number = "$(Get-DataProperty $Account 'Account')" $url = "$(Get-DataProperty $Account 'Url')".TrimEnd('/') $events = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ -and ("$(Get-DataProperty $_ 'Account')" -eq $number -or ($url -and "$(Get-DataProperty $_ 'Url')".TrimEnd('/') -eq $url)) } | ForEach-Object { ConvertTo-NextcloudTimedEvent -Folder $_ } | Where-Object { $_ -and "$($_.Lane)" -eq 'Server' }) $passed = @($events | Where-Object { "$($_.Mark)" -ne 'Denied' } | ForEach-Object { $_.Time } | Sort-Object) $newestPassed = $(if ($passed.Count) { $passed[-1] } else { $null }) $refused = @($events | Where-Object { "$($_.Mark)" -eq 'Denied' -and ($null -eq $newestPassed -or $_.Time -gt $newestPassed) } | ForEach-Object { $_.Time } | Sort-Object) if (-not $refused.Count) { return } [pscustomobject]@{ Count = $refused.Count; Since = $refused[0]; Last = $refused[-1] } } function Get-NextcloudAuthTypeText { <# .SYNOPSIS How an account signs in, in words: the client's authType. One this has no word for as the client wrote it; none as that. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$AuthType) # The values the client writes, by how the account was set up: only webflow was seen # (docs/research/nextcloud-sign-in.md); the others are the client's other kinds. $type = "$AuthType".Trim() switch ($type.ToLowerInvariant()) { '' { return (Get-Text 'Value.Nextcloud.AuthType.None') } 'webflow' { return (Get-Text 'Value.Nextcloud.AuthType.Webflow') } 'basic' { return (Get-Text 'Value.Nextcloud.AuthType.Basic') } 'oauth' { return (Get-Text 'Value.Nextcloud.AuthType.OAuth') } 'shibboleth' { return (Get-Text 'Value.Nextcloud.AuthType.Shibboleth') } } $type } function New-NextcloudSignInFinding { <# .SYNOPSIS Whether one account can sign in: a credential saved for it, and whether its server refused it since it last let it in. On the account's Service, host:443. ADR-0015, ADR-0016. .DESCRIPTION A WARN where no credential is saved (credential-missing) - the client cannot sign in without asking - and where the server refused the account after the last attempt it let through (credential-refused), with how often and since when. Where the Run is not in the User's Session it says it could not check, and what it would have looked for, never that all is well. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Account, [AllowNull()]$Data) $server = "$(Get-DataProperty $Account 'Server')" $url = "$(Get-DataProperty $Account 'Url')" $service = ConvertTo-UrlService -Url $url $subject = $(if ($service) { $service.Subject } else { $server }) $check = (Get-Text 'Check.Nextcloud.SignIn') -f $server $session = Get-UserSessionObstacle -RunUser (Get-DataProperty $Data 'RunUser') -IsElevated (Get-DataProperty $Data 'IsElevated') if ($session) { $value = Get-Text 'Value.Nextcloud.SignIn.Elevated' if ($session -eq 'OtherUser') { $value = (Get-Text 'Value.Nextcloud.SignIn.OtherUser') -f "$(Get-DataProperty (Get-DataProperty $Data 'RunUser') 'LoggedOnUser')" } return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value $value ` -Hint (Get-Text 'Hint.Nextcloud.SignIn.UserSession') -Unobserved @('credential-missing', 'credential-refused') } $storeError = "$(Get-DataProperty $Data 'SavedCredentialsError')".Trim() $saved = @(Select-NextcloudCredentialTarget -Target (Get-DataCollection $Data 'SavedCredentials') -Url $url -Account "$(Get-DataProperty $Account 'Account')") $missing = -not $storeError -and -not $saved.Count $refusal = Get-NextcloudRefusal -Account $Account -Data $Data $value = @((Get-Text 'Value.Nextcloud.SignIn.Type') -f (Get-NextcloudAuthTypeText (Get-DataProperty $Account 'AuthType'))) if ($storeError) { $value += (Get-Text 'Value.Nextcloud.SignIn.StoreUnread') -f $storeError } elseif ($missing) { $value += Get-Text 'Value.Nextcloud.SignIn.Missing' } else { $value += Get-Text 'Value.Nextcloud.SignIn.Saved' } if (-not [bool](Get-DataProperty $Account 'HasUser')) { $value += Get-Text 'Value.Nextcloud.SignIn.NoUser' } if ($refusal) { $value += (Get-Text 'Value.Nextcloud.SignIn.Refused') -f $refusal.Count, $refusal.Since, $refusal.Last } $reference = @(New-Reference -Section 'Title.Nextcloud.SignIn') $signal = @() $meaning = @() $hint = @() if ($missing) { $signal += 'credential-missing'; $meaning += Get-Text 'Meaning.Nextcloud.SignIn.Missing'; $hint += Get-Text 'Hint.Nextcloud.SignIn.Missing' } if ($refusal) { $signal += 'credential-refused'; $meaning += Get-Text 'Meaning.Nextcloud.SignIn.Refused'; $hint += Get-Text 'Hint.Nextcloud.SignIn.Refused' } if ($signal.Count) { $lastSeen = $(if ($refusal) { $refusal.Last } else { $null }) return New-Finding -Category Apps -Check $check -Severity WARN -Subject $subject -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Signal $signal -LastSeen $lastSeen -Reference $reference } if ($storeError) { return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') ` -Hint (Get-Text 'Hint.Nextcloud.SignIn.StoreUnread') -Unobserved @('credential-missing') -Reference $reference } New-Finding -Category Apps -Check $check -Severity OK -Subject $subject -Value ($value -join ' | ') -Reference $reference } function ConvertFrom-NextcloudStatus { <# .SYNOPSIS What status.php answered: Installed, Maintenance, NeedsDbUpgrade, Version and VersionString, or nothing where the body holds no answer of it. Pure. .DESCRIPTION The body is kept as it arrived, so a chunked answer still carries its chunk sizes; the JSON object is taken from the first brace to the last, as Autodiscover's is. Its keys as Nextcloud's status.php writes them: installed, maintenance, needsDbUpgrade, version ("30.0.4.1"), versionstring ("30.0.4"), productname. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) if (-not $Text) { return $null } $start = $Text.IndexOf('{') $end = $Text.LastIndexOf('}') if ($start -lt 0 -or $end -le $start) { return $null } try { $json = $Text.Substring($start, $end - $start + 1) | ConvertFrom-Json -ErrorAction Stop } catch { return $null } if (-not (Test-DataPropertyPresent -InputObject $json -Name 'maintenance')) { return $null } $flag = { param($Name) $v = Get-DataProperty $json $Name; ($v -eq $true) -or ("$v" -eq 'true') } [pscustomobject]@{ Installed = & $flag 'installed' Maintenance = & $flag 'maintenance' NeedsDbUpgrade = & $flag 'needsDbUpgrade' Version = "$(Get-DataProperty $json 'version')".Trim() VersionString = "$(Get-DataProperty $json 'versionstring')".Trim() } } function Test-NextcloudVersionDrift { <# .SYNOPSIS Whether the version the client last saw of its server differs from the one the server reports now. Compared by their numbers, "30.0.4.1" against "30.0.4.1"; not where either is unknown. Pure. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Known, [AllowNull()][AllowEmptyString()][string]$Reported) $numbers = { param($Text) @([regex]::Matches("$Text", '\d+') | ForEach-Object { [int]$_.Value }) } $a = & $numbers $Known $b = & $numbers $Reported if (-not $a.Count -or -not $b.Count) { return $false } # A part one of them leaves out is 0: 30.0.4 is 30.0.4.0. $length = [math]::Max($a.Count, $b.Count) for ($i = 0; $i -lt $length; $i++) { $x = $(if ($i -lt $a.Count) { $a[$i] } else { 0 }) $y = $(if ($i -lt $b.Count) { $b[$i] } else { 0 }) if ($x -ne $y) { return $true } } $false } function Get-NextcloudSchemeText { <# .SYNOPSIS A sign-in scheme a server offers, in words with its name: Basic, Bearer, Negotiate, NTLM; one this has no words for by its name alone. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Scheme) $name = "$Scheme".Trim() switch ($name.ToLowerInvariant()) { 'basic' { return (Get-Text 'Value.Nextcloud.Scheme.Basic') } 'bearer' { return (Get-Text 'Value.Nextcloud.Scheme.Bearer') } 'negotiate' { return (Get-Text 'Value.Nextcloud.Scheme.Negotiate') } 'ntlm' { return (Get-Text 'Value.Nextcloud.Scheme.Ntlm') } } $name } function Get-NextcloudProbeProblem { <# .SYNOPSIS Why a request to the server brought no answer of the server's, in words: the step it failed at and why, or the status a proxy answered with. Nothing where the server answered. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Probe) $failure = Get-DataProperty $Probe 'Error' if ($failure) { return '{0}: {1}' -f (Get-HttpsEndpointStageText (Get-DataProperty $Probe 'Stage')), "$(Get-DataProperty $failure 'Message')".Trim() } if ("$(Get-DataProperty $Probe 'AnsweredBy')" -eq 'Proxy') { return (Get-Text 'Value.Nextcloud.Server.ProxyAnswered') -f (Format-HttpStatus -Status (Get-DataProperty $Probe 'StatusCode') -Reason "$(Get-DataProperty $Probe 'Reason')") } '' } function Get-NextcloudSchemeValue { <# .SYNOPSIS What the anonymous request to remote.php/dav/ said of how to sign in, as a part of the server's Value. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Account) $dav = Get-DataProperty $Account 'DavProbe' if ($null -eq $dav) { return '' } $problem = Get-NextcloudProbeProblem -Probe $dav if ($problem) { return (Get-Text 'Value.Nextcloud.Server.SchemesNotAsked') -f $problem } $schemes = @((Get-DataCollection $Account 'Schemes') | ForEach-Object { Get-NextcloudSchemeText "$_" } | Where-Object { $_ }) if ($schemes.Count) { return (Get-Text 'Value.Nextcloud.Server.Schemes') -f ($schemes -join ', ') } (Get-Text 'Value.Nextcloud.Server.NoSchemes') -f (Format-HttpStatus -Status (Get-DataProperty $dav 'StatusCode') -Reason "$(Get-DataProperty $dav 'Reason')") } function New-NextcloudServerFinding { <# .SYNOPSIS What one account's server said of itself when asked without credentials: in maintenance or not, its version, and how it wants to be signed in to. On the account's Service, host:443. ADR-0015, ADR-0016. .DESCRIPTION A FAIL where status.php says maintenance or needsDbUpgrade (service-maintenance): while that lasts the server refuses every sign-in and every sync, whatever this machine does. An INFO where the server reports another version than the client last saw of it: it was updated, and the client has not connected since. Whether the server can be reached is the Server Check's and the Links' to say; a request that brought no answer is said here as INFO, and no more. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Account) $probe = Get-DataProperty $Account 'StatusProbe' if ($null -eq $probe) { return } $server = "$(Get-DataProperty $Account 'Server')" $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Account 'Url')" $subject = $(if ($service) { $service.Subject } else { $server }) $check = (Get-Text 'Check.Nextcloud.Server') -f $server $askedAt = ConvertTo-DataTime (Get-DataProperty $Account 'AskedAt') $schemes = Get-NextcloudSchemeValue -Account $Account $problem = Get-NextcloudProbeProblem -Probe $probe $status = $null if (-not $problem) { $status = ConvertFrom-NextcloudStatus -Text "$(Get-DataProperty $probe 'Body')" if ($null -eq $status) { $problem = (Get-Text 'Value.Nextcloud.Server.NoStatus') -f (Format-HttpStatus -Status (Get-DataProperty $probe 'StatusCode') -Reason "$(Get-DataProperty $probe 'Reason')") } } if ($problem) { $value = @((Get-Text 'Value.Nextcloud.Server.NotAsked') -f $problem) if ($schemes) { $value += $schemes } return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') ` -Hint (Get-Text 'Hint.Nextcloud.Server.NotAsked') -Unobserved @('service-maintenance') } $value = @() if ($status.Maintenance) { $value += (Get-Text 'Value.Nextcloud.Server.Maintenance') -f $askedAt } if ($status.NeedsDbUpgrade) { $value += (Get-Text 'Value.Nextcloud.Server.DbUpgrade') -f $askedAt } if (-not $status.Maintenance -and -not $status.NeedsDbUpgrade) { $value += Get-Text 'Value.Nextcloud.Server.Ready' } $version = $(if ($status.VersionString) { $status.VersionString } else { $status.Version }) if ($version) { $value += (Get-Text 'Value.Nextcloud.Server.Version') -f $version } $known = "$(Get-DataProperty $Account 'ServerVersion')".Trim() $drift = Test-NextcloudVersionDrift -Known $known -Reported $status.Version if ($drift) { $value += (Get-Text 'Value.Nextcloud.Server.Drift') -f $known, $status.Version } if ($schemes) { $value += $schemes } if ($status.Maintenance -or $status.NeedsDbUpgrade) { return New-Finding -Category Apps -Check $check -Severity FAIL -Subject $subject -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.Nextcloud.Server.Maintenance') -Hint (Get-Text 'Hint.Nextcloud.Server.Maintenance') ` -Signal @('service-maintenance') } if ($drift) { return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.Nextcloud.Server.Drift') -Hint (Get-Text 'Hint.Nextcloud.Server.Drift') } New-Finding -Category Apps -Check $check -Severity OK -Subject $subject -Value ($value -join ' | ') } # How the client wrote an HTTP error up to 3.16: 'Server replied "401 Unauthorized" to # "PROPFIND https://..."'. From 3.17 on it writes a sentence, and no status. $script:NextcloudReplyPattern = '^Server replied "(?<status>\d{3})(?: (?<reason>[^"]*))?" to "(?<request>[^"]*)"' function Get-NextcloudReplyStatus { <# .SYNOPSIS The HTTP status a reason the client wrote names, or nothing. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Text) if ("$Text".Trim() -match $script:NextcloudReplyPattern) { return $Matches['status'] } '' } function Format-NextcloudReason { <# .SYNOPSIS A reason the client wrote, in the words of the Report: the status of an HTTP error with what it means, a sentence as it is. Pure. .DESCRIPTION Up to client 3.16 an HTTP error is 'Server replied "401 Unauthorized" to "PROPFIND https://cloud.contoso.example/remote.php/dav/files/..."', the URL said in full: the status is said as Format-HttpStatus says every one, with the reason the server gave where this has no word for it, so that no number stands alone. From 3.17 on the client writes a sentence chosen by the status, which is said as it is. The one category the client keeps apart, the server being full, is said as that. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Text, [AllowNull()]$Category) $said = "$Text".Trim() if ($said -match $script:NextcloudReplyPattern) { $status = Format-HttpStatus -Status $Matches['status'] -Reason $Matches['reason'] $said = (Get-Text 'Value.Nextcloud.Timeline.Replied') -f $status, $Matches['request'].Trim() } if (-not $said) { $said = Get-Text 'Value.Nextcloud.NoReason' } if ((ConvertTo-Number $Category) -eq 1) { $said = (Get-Text 'Value.Nextcloud.ServerFull') -f $said } $said } function ConvertTo-NextcloudTimedEvent { <# .SYNOPSIS Each file of one folder still failing, at the client's last attempt, on the HTTPS Service of the account's server, and how far back the journal reaches. Pure. ADR-0010. .DESCRIPTION The event is the host:port Service's - cloud.contoso.example:443, on the port the account's address names (ConvertTo-UrlService) - which the Server Check and the Links draw too, and not a URL Service of its own: the client asked its server, whatever path it was. The journal holds a row per file only while the file fails, overwritten at each attempt, and drops it once the file syncs. So an event is the last attempt at a file that still fails, and its words say that the journal is no history: a file that failed the week before and syncs now is not in it. How far back it reaches is the oldest attempt among the rows read, a Reaches event of its Log, in the same words. Nothing is drawn of a folder with nothing failing. An attempt whose time cannot be read is left out: its Finding counts it, and the timeline cannot place it. .PARAMETER Finding The Finding about the folder, which counts its files set aside. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Folder, [AllowNull()]$Finding) $journal = Get-DataProperty $Folder 'Journal' if ($null -eq $journal -or -not [bool](Get-DataProperty $journal 'Read')) { return } $server = "$(Get-DataProperty $Folder 'Server')".Trim() if (-not $server) { return } $port = 443 $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Folder 'Url')" if ($service) { $port = $service.Port } $log = (Get-Text 'Value.Nextcloud.Timeline.Log') -f (Get-NextcloudFolderName -Path "$(Get-DataProperty $Folder 'Path')") $note = Get-Text 'Value.Nextcloud.Timeline.StillFailing' $oldest = $null foreach ($failure in @((Get-DataCollection $journal 'Failures') | Where-Object { $_ })) { $at = ConvertTo-DataTime (Get-DataProperty $failure 'Time') if ($null -eq $at) { continue } if ($null -eq $oldest -or $at -lt $oldest) { $oldest = $at } $text = "$(Get-DataProperty $failure 'Text')" $reason = Format-NextcloudReason -Text $text -Category (Get-DataProperty $failure 'Category') New-TimedEvent -Time $at -Lane Server -Mark (Get-TimedEventStatusMark -Status (Get-NextcloudReplyStatus -Text $text)) ` -Subject $server -Port $port -Log $log -Finding $Finding ` -Detail ((Get-Text 'Value.Nextcloud.Timeline.Failed') -f $reason, $note) } # How far back the journal reaches: its oldest row, and no history before it. if ($null -ne $oldest) { New-TimedEvent -Time $oldest -Lane Log -Mark Reaches -Log $log -Detail $note } } function Get-NextcloudFolderName { <# .SYNOPSIS A sync folder as the Timeline names its journal, without the User's folder in it: C:\Users\max.mustermann\Nextcloud is %USERPROFILE%\Nextcloud, a folder elsewhere its own name, D:\Daten\Nextcloud is Nextcloud. Pure. .DESCRIPTION The name of a Log is on every event of it, in the timeline the Report carries and in timeline.csv; as the AppLog Kind names a log by the path the Definition writes (Get-AppLogLogName), the User is not in it. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Path) $text = "$Path".Trim().TrimEnd('\', '/') if ($text -match '^[A-Za-z]:[\\/]Users[\\/][^\\/]+(?<rest>([\\/].*)?)$') { return ('%USERPROFILE%' + ($Matches['rest'] -replace '/', '\')) } @($text -split '[\\/]')[-1] } function ConvertTo-NextcloudFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) New-NextcloudSetupFinding -Data $Data foreach ($folder in @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })) { $sync = New-NextcloudSyncFinding -Folder $folder -Data $Data -Parameters $Parameters $sync # The files still failing on the Timeline, by the Finding that counted them. ADR-0010. ConvertTo-NextcloudTimedEvent -Folder $folder -Finding $sync } # Whether each account can sign in, on its Service. ADR-0016. # And what its server said of itself, asked without credentials, with the request to # status.php measured on the account's Service as the HttpsEndpoint Kind measures one. # ADR-0009. Once per address, however many accounts share it. $accounts = @((Get-DataCollection $Data 'Accounts') | Where-Object { $_ -and "$(Get-DataProperty $_ 'Url')" }) $links = @() $measured = @{} foreach ($account in $accounts) { New-NextcloudSignInFinding -Account $account -Data $Data $url = "$(Get-DataProperty $account 'Url')" if ($measured.ContainsKey($url)) { continue } $measured[$url] = $true $serverFinding = New-NextcloudServerFinding -Account $account if (-not $serverFinding) { continue } $serverFinding $probe = Get-DataProperty $account 'StatusProbe' ConvertTo-HttpsEndpointMeasurement -Target ([pscustomobject]@{ Url = $url; HostName = "$(Get-DataProperty $account 'Server')" }) ` -Observation $probe -Data $Data -Finding $serverFinding -VpnPattern $script:MeasurementVpnAdapterPattern $links += New-ServiceLink -Url $url -Address (Get-HttpsEndpointAnsweredAt -Observation $probe) } # And the server of each account, for the Network Map: once, however many folders; at # the address it answered from, where it was asked. $urls = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ } | ForEach-Object { "$(Get-DataProperty $_ 'Url')" } | Where-Object { $_ } | Select-Object -Unique) Select-UniqueLink -Link @($links + @($urls | ForEach-Object { New-ServiceLink -Url $_ })) } function ConvertTo-NextcloudSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) ConvertTo-NextcloudSignInSection -Data $Data $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ }) if (-not $folders.Count) { return } New-Section -Title (Get-Text 'Title.Nextcloud.Folders') -Help (Get-Text 'Help.Nextcloud.Folders') -Row @( $folders | ForEach-Object { $journal = Get-DataProperty $_ 'Journal' $read = [bool](Get-DataProperty $journal 'Read') $n = { param($Name) if ($read) { ConvertTo-Number (Get-DataProperty $journal $Name) } } $row = [ordered]@{} $row[(Get-Text 'Column.Nextcloud.Folder')] = "$(Get-DataProperty $_ 'Path')" $row[(Get-Text 'Column.Nextcloud.Server')] = "$(Get-DataProperty $_ 'Server')" $row[(Get-Text 'Column.Nextcloud.JournalAt')] = Get-DataProperty $_ 'JournalWritten' $row[(Get-Text 'Column.Nextcloud.Files')] = & $n 'Files' $row[(Get-Text 'Column.Nextcloud.Placeholders')] = & $n 'Placeholders' $row[(Get-Text 'Column.Nextcloud.Folders')] = & $n 'Folders' $row[(Get-Text 'Column.Nextcloud.Failed')] = $(if ($read) { [int](((Get-DataCollection $journal 'Errors') | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum) }) $row[(Get-Text 'Column.Nextcloud.Conflicts')] = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts')) }) $row[(Get-Text 'Column.Nextcloud.Moving')] = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads')) }) $row[(Get-Text 'Column.Nextcloud.Paused')] = Get-TriStateText ([bool](Get-DataProperty $_ 'Paused')) $row[(Get-Text 'Column.Nextcloud.Virtual')] = Get-TriStateText ([bool](Get-DataProperty $_ 'Virtual')) [pscustomobject]$row } ) $rows = @($folders | ForEach-Object { $path = "$(Get-DataProperty $_ 'Path')" (Get-DataCollection (Get-DataProperty $_ 'Journal') 'Errors') | Where-Object { $_ } | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.Nextcloud.Folder')] = $path $row[(Get-Text 'Column.Nextcloud.Count')] = [int](ConvertTo-Number (Get-DataProperty $_ 'Count')) $row[(Get-Text 'Column.Nextcloud.Reason')] = "$(Get-DataProperty $_ 'Text')" $row[(Get-Text 'Column.Nextcloud.Last')] = Get-DataProperty $_ 'Last' [pscustomobject]$row } }) if ($rows.Count) { New-Section -Title (Get-Text 'Title.Nextcloud.Errors') -Help (Get-Text 'Help.Nextcloud.Errors') -Row $rows } } function ConvertTo-NextcloudSignInSection { <# .SYNOPSIS Each account with how it signs in, whether a credential is saved for it and under which target names. Nothing where there is no account. Pure. .DESCRIPTION Read in the Session the Run is in: where that is not the User's, what is saved says it was not read, as the Finding does, and no target name of another account stands here. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $accounts = @((Get-DataCollection $Data 'Accounts') | Where-Object { $_ -and "$(Get-DataProperty $_ 'Url')" }) if (-not $accounts.Count) { return } $session = Get-UserSessionObstacle -RunUser (Get-DataProperty $Data 'RunUser') -IsElevated (Get-DataProperty $Data 'IsElevated') $storeError = "$(Get-DataProperty $Data 'SavedCredentialsError')".Trim() New-Section -Title (Get-Text 'Title.Nextcloud.SignIn') -Help (Get-Text 'Help.Nextcloud.SignIn') -Row @( $accounts | ForEach-Object { $url = "$(Get-DataProperty $_ 'Url')" $saved = @(Select-NextcloudCredentialTarget -Target (Get-DataCollection $Data 'SavedCredentials') -Url $url -Account "$(Get-DataProperty $_ 'Account')") $state = Get-TriStateText ([bool]$saved.Count) if ($storeError -or $session) { $state = Get-Text 'Value.Nextcloud.SignIn.Unread' } $row = [ordered]@{} $row[(Get-Text 'Column.Nextcloud.Account')] = $url $row[(Get-Text 'Column.Nextcloud.AuthType')] = Get-NextcloudAuthTypeText (Get-DataProperty $_ 'AuthType') $row[(Get-Text 'Column.Nextcloud.HasUser')] = Get-TriStateText ([bool](Get-DataProperty $_ 'HasUser')) $row[(Get-Text 'Column.Nextcloud.Saved')] = $state $row[(Get-Text 'Column.Nextcloud.Target')] = $(if (-not $session) { $saved -join '; ' }) # What the server said of itself, asked without credentials (#179). $probe = Get-DataProperty $_ 'StatusProbe' $status = $(if ($probe -and -not (Get-NextcloudProbeProblem -Probe $probe)) { ConvertFrom-NextcloudStatus -Text "$(Get-DataProperty $probe 'Body')" }) $row[(Get-Text 'Column.Nextcloud.ServerState')] = $( if ($null -eq $probe) { Get-Text 'Value.Nextcloud.Server.State.NotAsked' } elseif ($null -eq $status) { Get-Text 'Value.Nextcloud.Server.State.Unknown' } elseif ($status.Maintenance -or $status.NeedsDbUpgrade) { Get-Text 'Value.Nextcloud.Server.State.Maintenance' } else { Get-Text 'Value.Nextcloud.Server.State.Ready' }) $row[(Get-Text 'Column.Nextcloud.ServerVersion')] = $(if ($status) { $status.Version }) $row[(Get-Text 'Column.Nextcloud.Schemes')] = (@((Get-DataCollection $_ 'Schemes') | ForEach-Object { Get-NextcloudSchemeText "$_" }) -join ', ') [pscustomobject]$row } ) } |