Public/Sharing.ps1

<#
.SYNOPSIS
    Lists all shared secure links for the authenticated user
.DESCRIPTION
    Retrieves all share links created by the authenticated Keepit user. Each share represents
    a secure link to a file or folder hierarchy from a backup snapshot. Returns share metadata
    including expiration, password protection status, and associated connector.
.EXAMPLE
    Get-KeepitShare

    Lists all active shares for the connected user
.EXAMPLE
    Get-KeepitShare | Where-Object { $_.HasPassword -eq $false }

    Lists all shares that are not password-protected
.OUTPUTS
    PSCustomObject[] - Array of share objects with properties:
        - ShareId: The share GUID
        - Path: The shared path
        - Created: Creation timestamp
        - Expires: Expiration timestamp
        - ConnectorGuid: The connector GUID
        - Snapshot: The snapshot ID (if pinned to a specific snapshot)
        - HasPassword: Whether the share is password-protected
        - DisplayName: Human-readable alias
        - Size: Size of shared data
.NOTES
    Requires an active connection via Connect-KeepitService.
#>

function Get-KeepitShare {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param()

    try {
        Write-Verbose "=== Get-KeepitShare ==="

        # Get authentication header and base URL
        $authHeader = Get-AuthHeader
        $baseUrl = Get-KeepitBaseUrl
        Write-Verbose "Base URL: $baseUrl"

        # Build request
        $uri = "$baseUrl/share/"
        $headers = @{
            'Authorization' = $authHeader
            'Content-Type'  = 'application/xml'
        }

        Write-Verbose "=== API Request Details ==="
        Write-Verbose "Method: GET"
        Write-Verbose "URI: $uri"

        # Make API call
        $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop

        Write-Verbose "=== API Response Received ==="
        Write-Verbose "Response Type: $($response.GetType().FullName)"

        # Parse response
        $shares = @()
        if ($response.shares.share) {
            # Normalize to array
            if ($response.shares.share -is [System.Array]) {
                $shares = $response.shares.share
                Write-Verbose "Found $($shares.Count) shares"
            }
            else {
                $shares = @($response.shares.share)
                Write-Verbose "Found 1 share"
            }
        }
        else {
            Write-Verbose "No shares found"
            return
        }

        # Process and output each share
        foreach ($share in $shares) {
            [PSCustomObject]@{
                ShareId       = if ($share.guid) { $share.guid } else { $null }
                Path          = if ($share.path) { $share.path } else { $null }
                Created       = if ($share.created) { $share.created } else { $null }
                Expires       = if ($share.expires) { $share.expires } else { $null }
                ConnectorGuid = if ($share.device) { $share.device } else { $null }
                Snapshot      = if ($share.snapshot -and $share.snapshot -ne '') { $share.snapshot } else { $null }
                HasPassword   = if ($share.password -and $share.password -ne '') { $true } else { $false }
                DisplayName   = if ($share.dname) { $share.dname } else { $null }
                Size          = if ($share.size) { [long]$share.size } else { $null }
            }
        }

        Write-Verbose "=== End Get-KeepitShare ==="
    }
    catch {
        $PSCmdlet.ThrowTerminatingError(
            [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Failed to retrieve shares: $($_.Exception.Message)", $_.Exception),
                'KeepitApiError',
                [System.Management.Automation.ErrorCategory]::ConnectionError,
                $null
            )
        )
    }
}

<#
.SYNOPSIS
    Creates a new shared secure link for a backup path
.DESCRIPTION
    Generates a share link for a file or folder hierarchy from a Keepit backup.
    The share can optionally be password-protected and set to expire after a given period.
    If no snapshot is specified, the latest snapshot is used.
.PARAMETER Connector
    Name or GUID of the Keepit connector. Accepts pipeline input by property name.
.PARAMETER Path
    The path to share. Must start with /. Directory paths must end with /.
    File paths must end with the filename.
.PARAMETER Lifetime
    ISO 8601 duration for how long the share should remain active (e.g., P30D for 30 days,
    PT1H for 1 hour). If not specified, the share does not expire.
.PARAMETER Snapshot
    Specific snapshot ID to pin the share to. If not specified, the latest snapshot is used.
.PARAMETER Password
    SecureString password to protect the share. Recipients will need to provide this password
    to access the shared content.
.EXAMPLE
     New-KeepitShare -Connector "ExO Only" -Path "/Users/pro@keepit.com/Outlook/" -Lifetime "P30D"

    Creates an unprotected share of the user's folder hierarchy using the latest snapshot, expiring in 30 days.
.EXAMPLE
    $pw = Read-Host -AsSecureString "Share password"
    New-KeepitShare -Connector "abc123-def456-ghi789" -Path "/Users/pro@keepit.com/OneDrive/data/report.pdf" -Password $pw

    Creates a password-protected share for a single file
.OUTPUTS
    PSCustomObject with properties:
        - ShareId: The GUID of the newly created share
        - ShareUrl: The full URL to access the share
        - ConnectorGuid: The connector GUID
        - Path: The shared path
        - Lifetime: The share lifetime (if specified)
.NOTES
    Requires an active connection via Connect-KeepitService.
    Path rules: must start with /, directories end with /, files end with filename.
#>

function New-KeepitShare {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('ConnectorGuid', 'Name')]
        [string]$Connector,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^P(?=\d|T\d)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?$')]
        [string]$Lifetime,

        [Parameter(Mandatory = $false)]
        [string]$Snapshot,

        [Parameter(Mandatory = $false)]
        [SecureString]$Password
    )

    begin {
        Write-Verbose "=== New-KeepitShare: Initialization ==="

        # Get authentication header and base URL once for all pipeline items
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            Write-Verbose "Base URL: $baseUrl"
            Write-Verbose "Initialization completed successfully"
        }
        catch {
            throw
        }
    }

    process {
        try {
            # Resolve connector identity to GUID
            $resolved = Resolve-KeepitConnectorIdentity -Identity $Connector
            $connectorGuid = $resolved.ConnectorGuid
            Write-Verbose "=== New-KeepitShare: Processing Connector ==="
            Write-Verbose "Connector: $($resolved.Name) ($connectorGuid)"

            # Ensure trailing slash (directory share)
            if (-not $Path.EndsWith('/')) {
                $Path = $Path + '/'
            }

            # XML-escape the path
            $escapedPath = [System.Security.SecurityElement]::Escape($Path)

            # Build XML request body
            $xmlBody = "<share><device>$connectorGuid</device><path>$escapedPath</path>"

            if ($PSBoundParameters.ContainsKey('Lifetime')) {
                $xmlBody += "<lifetime>$Lifetime</lifetime>"
            }

            if ($PSBoundParameters.ContainsKey('Snapshot')) {
                $escapedSnapshot = [System.Security.SecurityElement]::Escape($Snapshot)
                $xmlBody += "<snapshot>$escapedSnapshot</snapshot>"
            }

            if ($PSBoundParameters.ContainsKey('Password')) {
                # Convert SecureString to plain text
                $tempCred = New-Object System.Management.Automation.PSCredential('unused', $Password)
                $plainPassword = $tempCred.GetNetworkCredential().Password
                $escapedPassword = [System.Security.SecurityElement]::Escape($plainPassword)
                $xmlBody += "<password>$escapedPassword</password>"
            }

            $xmlBody += "</share>"
            $plainPassword = $null

            Write-Verbose "=== API Request Details ==="
            Write-Verbose "Method: POST"
            Write-Verbose "URI: $baseUrl/share/"
            Write-Verbose "Content-Type: application/xml"
            Write-Verbose "Request Body:`n$($xmlBody -replace '<password>[^<]*</password>', '<password>***</password>')"

            # Build request
            $uri = "$baseUrl/share/"
            $headers = @{
                'Authorization' = $authHeader
                'Content-Type'  = 'application/xml'
            }

            Write-Verbose "=== Sending API Request ==="

            if ($PSCmdlet.ShouldProcess("$Path on connector $connectorGuid", 'Create share')) {
                # Use Invoke-WebRequest to capture Location header
                $webResponse = Invoke-WebRequest -Uri $uri -Method Post -Headers $headers -Body $xmlBody -SkipHttpErrorCheck -ErrorAction Stop

                Write-Verbose "=== API Response Received ==="
                Write-Verbose "Status Code: $($webResponse.StatusCode)"

                # Check for HTTP errors
                if ($webResponse.StatusCode -ge 400) {
                    $errorBody = $webResponse.Content
                    Write-Verbose "Error response body: $errorBody"

                    $apiError = $null
                    if ($errorBody) {
                        try {
                            $errorXml = [xml]$errorBody
                            $apiError = [PSCustomObject]@{
                                Code        = $errorXml.error.code
                                Description = $errorXml.error.description
                            }
                            Write-Verbose "Parsed API Error - Code: $($apiError.Code), Description: $($apiError.Description)"
                        }
                        catch {
                            Write-Verbose "Could not parse error response as XML: $($_.Exception.Message)"
                        }
                    }

                    if ($apiError -and $apiError.Code) {
                        throw "Failed to create share: [$($apiError.Code)] $($apiError.Description)"
                    }
                    else {
                        throw "Failed to create share: HTTP $($webResponse.StatusCode) - $errorBody"
                    }
                }

                # Extract share GUID from Location header
                $shareId = $null
                $shareUrl = $null
                $locationHeader = $null
                if ($webResponse.Headers -and $webResponse.Headers.ContainsKey('Location')) {
                    $locationValue = $webResponse.Headers['Location']
                    $locationHeader = if ($locationValue -is [System.Array]) { $locationValue[0] } else { $locationValue }
                }

                if ($locationHeader) {
                    Write-Verbose "Location header: $locationHeader"

                    # Build full URL: if the Location header is a relative path, prepend the base URL
                    if ($locationHeader -match '^https?://') {
                        $shareUrl = $locationHeader
                    }
                    else {
                        $shareUrl = $baseUrl + $locationHeader
                    }

                    # Extract GUID from Location URL (e.g., /share/{guid}/ or https://host/share/{guid}/)
                    if ($locationHeader -match '/share/([0-9a-fA-F-]+)') {
                        $shareId = $Matches[1]
                        Write-Verbose "Extracted share GUID from Location header: $shareId"
                    }
                }

                # Build and return result object
                [PSCustomObject]@{
                    ShareId       = $shareId
                    ShareUrl      = $shareUrl
                    ConnectorGuid = $connectorGuid
                    Path          = $Path
                    Lifetime      = if ($PSBoundParameters.ContainsKey('Lifetime')) { $Lifetime } else { $null }
                }

                Write-Verbose "=== Share Created Successfully ==="
            }
        }
        catch {
            $errorMessage = $_.Exception.Message
            if ($errorMessage -like "Failed to create share:*") {
                throw
            }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to create share: $errorMessage", $_.Exception),
                    'KeepitApiError',
                    [System.Management.Automation.ErrorCategory]::ConnectionError,
                    $connectorGuid
                )
            )
        }
    }
}

<#
.SYNOPSIS
    Updates properties of an existing shared secure link
.DESCRIPTION
    Modifies the lifetime, password, or snapshot of an existing share. Only specified
    properties are updated; unspecified properties remain unchanged. Use -ClearPassword
    to remove password protection, and -ClearSnapshot to unpin from a specific snapshot.
.PARAMETER ShareId
    The GUID of the share to update. Accepts pipeline input by property name.
.PARAMETER Lifetime
    New ISO 8601 duration for the share (e.g., P7D for 7 days). Empty string means never expire.
.PARAMETER Password
    New SecureString password for the share.
.PARAMETER ClearPassword
    Switch to remove password protection from the share.
.PARAMETER Snapshot
    New snapshot ID to pin the share to.
.PARAMETER ClearSnapshot
    Switch to unpin the share from a specific snapshot (reverts to latest).
.EXAMPLE
    Set-KeepitShare -ShareId "abc123-def456" -Lifetime "P7D"

    Updates the share to expire in 7 days
.EXAMPLE
    Get-KeepitShare | Set-KeepitShare -Lifetime "P7D"

    Updates all shares to expire in 7 days via pipeline
.EXAMPLE
    Set-KeepitShare -ShareId "abc123-def456" -ClearPassword

    Removes password protection from the share
.EXAMPLE
    Set-KeepitShare -ShareId "abc123-def456" -Lifetime "P7D" -WhatIf

    Shows what would happen without making changes
.OUTPUTS
    PSCustomObject with properties:
        - ShareId: The share GUID
        - Status: "Success" or error message
.NOTES
    Requires an active connection via Connect-KeepitService.
    Supports -WhatIf and -Confirm.
    If -Password and -ClearPassword are both specified, -Password takes precedence.
    If -Snapshot and -ClearSnapshot are both specified, -Snapshot takes precedence.
#>

function Set-KeepitShare {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^[a-zA-Z0-9\-]+$')]
        [string]$ShareId,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^P(?=\d|T\d)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?$')]
        [string]$Lifetime,

        [Parameter(Mandatory = $false)]
        [SecureString]$Password,

        [Parameter(Mandatory = $false)]
        [switch]$ClearPassword,

        [Parameter(Mandatory = $false)]
        [string]$Snapshot,

        [Parameter(Mandatory = $false)]
        [switch]$ClearSnapshot
    )

    begin {
        Write-Verbose "=== Set-KeepitShare: Initialization ==="

        # Get authentication header and base URL once for all pipeline items
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            Write-Verbose "Base URL: $baseUrl"
            Write-Verbose "Initialization completed successfully"
        }
        catch {
            throw
        }
    }

    process {
        try {
            Write-Verbose "=== Set-KeepitShare: Processing Share $ShareId ==="

            # Check if any changes were specified
            $hasChanges = $PSBoundParameters.ContainsKey('Lifetime') -or
                          $PSBoundParameters.ContainsKey('Password') -or
                          $ClearPassword.IsPresent -or
                          $PSBoundParameters.ContainsKey('Snapshot') -or
                          $ClearSnapshot.IsPresent

            if (-not $hasChanges) {
                Write-Warning "No changes specified for share $ShareId. Use -Lifetime, -Password, -ClearPassword, -Snapshot, or -ClearSnapshot."
                return
            }

            # Build XML body with only specified elements
            $xmlBody = "<share>"

            if ($PSBoundParameters.ContainsKey('Lifetime')) {
                $xmlBody += "<lifetime>$Lifetime</lifetime>"
            }

            if ($PSBoundParameters.ContainsKey('Password')) {
                # -Password takes precedence over -ClearPassword
                $tempCred = New-Object System.Management.Automation.PSCredential('unused', $Password)
                $plainPassword = $tempCred.GetNetworkCredential().Password
                $escapedPassword = [System.Security.SecurityElement]::Escape($plainPassword)
                $plainPassword = $null
                $xmlBody += "<password>$escapedPassword</password>"
            }
            elseif ($ClearPassword.IsPresent) {
                $xmlBody += "<password></password>"
            }

            if ($PSBoundParameters.ContainsKey('Snapshot')) {
                # -Snapshot takes precedence over -ClearSnapshot
                $escapedSnapshot = [System.Security.SecurityElement]::Escape($Snapshot)
                $xmlBody += "<snapshot>$escapedSnapshot</snapshot>"
            }
            elseif ($ClearSnapshot.IsPresent) {
                $xmlBody += "<snapshot></snapshot>"
            }

            $xmlBody += "</share>"

            Write-Verbose "=== API Request Details ==="
            Write-Verbose "Method: PUT"
            Write-Verbose "URI: $baseUrl/share/$ShareId"
            Write-Verbose "Content-Type: application/xml"
            Write-Verbose "Request Body:`n$($xmlBody -replace '<password>[^<]*</password>', '<password>***</password>')"

            if ($PSCmdlet.ShouldProcess("Share $ShareId", "Update share")) {
                # Build request
                $uri = "$baseUrl/share/$ShareId"
                $headers = @{
                    'Authorization' = $authHeader
                    'Content-Type'  = 'application/xml'
                }

                Write-Verbose "=== Sending API Request ==="

                $null = Invoke-RestMethod -Uri $uri -Method Put -Headers $headers -Body $xmlBody -ErrorAction Stop

                Write-Verbose "=== Share Updated Successfully ==="

                [PSCustomObject]@{
                    ShareId = $ShareId
                    Status  = 'Success'
                }
            }
        }
        catch {
            $errorMessage = $_.Exception.Message
            if ($errorMessage -like "Failed to update share:*") {
                throw
            }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to update share: $errorMessage", $_.Exception),
                    'KeepitApiError',
                    [System.Management.Automation.ErrorCategory]::WriteError,
                    $ShareId
                )
            )
        }
    }
}

<#
.SYNOPSIS
    Deletes a shared secure link
.DESCRIPTION
    Permanently removes a share link. The share URL will no longer be accessible.
    Only shares owned by the authenticated user can be deleted.
.PARAMETER ShareId
    The GUID of the share to delete. Accepts pipeline input by property name.
.EXAMPLE
    Remove-KeepitShare -ShareId "abc123-def456"

    Deletes the specified share
.EXAMPLE
    Get-KeepitShare | Remove-KeepitShare

    Deletes all shares for the connected user
.EXAMPLE
    Get-KeepitShare | Where-Object ConnectorGuid -eq $guid | Remove-KeepitShare

    Deletes all shares for a specific connector
.EXAMPLE
    Get-KeepitShare | Remove-KeepitShare -WhatIf

    Shows which shares would be deleted without actually deleting them
.OUTPUTS
    PSCustomObject with properties:
        - ShareId: The share GUID
        - Status: "Success" or error message
.NOTES
    Requires an active connection via Connect-KeepitService.
    Supports -WhatIf and -Confirm.
#>

function Remove-KeepitShare {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^[a-zA-Z0-9\-]+$')]
        [string]$ShareId
    )

    begin {
        Write-Verbose "=== Remove-KeepitShare: Initialization ==="

        # Get authentication header and base URL once for all pipeline items
        try {
            $authHeader = Get-AuthHeader
            $baseUrl = Get-KeepitBaseUrl
            Write-Verbose "Base URL: $baseUrl"
            Write-Verbose "Initialization completed successfully"
        }
        catch {
            throw
        }
    }

    process {
        try {
            Write-Verbose "=== Remove-KeepitShare: Processing Share $ShareId ==="

            if ($PSCmdlet.ShouldProcess("Share $ShareId", "Delete share")) {
                # Build request
                $uri = "$baseUrl/share/$ShareId"
                $headers = @{
                    'Authorization' = $authHeader
                    'Content-Type'  = 'application/xml'
                }

                Write-Verbose "=== API Request Details ==="
                Write-Verbose "Method: DELETE"
                Write-Verbose "URI: $uri"

                Write-Verbose "=== Sending API Request ==="

                $null = Invoke-RestMethod -Uri $uri -Method Delete -Headers $headers -ErrorAction Stop

                Write-Verbose "=== Share Deleted Successfully ==="

                [PSCustomObject]@{
                    ShareId = $ShareId
                    Status  = 'Success'
                }
            }
        }
        catch {
            $errorMessage = $_.Exception.Message
            if ($errorMessage -like "Failed to delete share:*") {
                throw
            }
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Failed to delete share: $errorMessage", $_.Exception),
                    'KeepitApiError',
                    [System.Management.Automation.ErrorCategory]::WriteError,
                    $ShareId
                )
            )
        }
    }
}