Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
358 lines
16 KiB
Go
358 lines
16 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
)
|
|
|
|
const SessionAttributesPropertyGroupName = "session_attributes"
|
|
|
|
const (
|
|
SessionAttributePlatformDesktop = "desktop"
|
|
SessionAttributePlatformMobile = "mobile"
|
|
SessionAttributePlatformBrowser = "browser"
|
|
)
|
|
|
|
const (
|
|
SessionAttributesPropertyFieldClientIPAddress = "client_ip_address"
|
|
SessionAttributesPropertyFieldNetworkInterfaceType = "network_interface_type"
|
|
SessionAttributesPropertyFieldVPNActive = "vpn_active"
|
|
SessionAttributesPropertyFieldSSID = "ssid"
|
|
SessionAttributesPropertyFieldTLSDDeviceID = "tls_device_id"
|
|
SessionAttributesPropertyFieldClientDeviceID = "client_device_id"
|
|
SessionAttributesPropertyFieldMDMEnrolled = "mdm_enrolled"
|
|
SessionAttributesPropertyFieldHardwareID = "hardware_id"
|
|
SessionAttributesPropertyFieldOSPlatform = "os_platform"
|
|
SessionAttributesPropertyFieldOSVersion = "os_version"
|
|
SessionAttributesPropertyFieldClientVersion = "client_version"
|
|
SessionAttributesPropertyFieldJailbreakDetected = "jailbreak_detected"
|
|
SessionAttributesPropertyFieldServerFQDN = "server_fqdn"
|
|
SessionAttributesPropertyFieldClientFQDN = "client_fqdn"
|
|
SessionAttributesPropertyFieldUserAgentPlatform = "user_agent_platform"
|
|
SessionAttributesPropertyFieldUserAgentOS = "user_agent_os"
|
|
SessionAttributesPropertyFieldUserAgentBrowserName = "user_agent_browser_name"
|
|
SessionAttributesPropertyFieldUserAgentBrowserVersion = "user_agent_browser_version"
|
|
SessionAttributesPropertyFieldIPAddress = "ip_address"
|
|
)
|
|
|
|
const (
|
|
SessionAttributesDisplayNameClientIPAddress = "Client IP address"
|
|
SessionAttributesDisplayNameNetworkInterfaceType = "Network interface type"
|
|
SessionAttributesDisplayNameVPNActive = "VPN active"
|
|
SessionAttributesDisplayNameSSID = "SSID"
|
|
SessionAttributesDisplayNameTLSDDeviceID = "TLS device ID"
|
|
SessionAttributesDisplayNameClientDeviceID = "Device ID"
|
|
SessionAttributesDisplayNameMDMEnrolled = "MDM enrolled"
|
|
SessionAttributesDisplayNameHardwareID = "Hardware ID"
|
|
SessionAttributesDisplayNameOSPlatform = "OS platform"
|
|
SessionAttributesDisplayNameOSVersion = "OS version"
|
|
SessionAttributesDisplayNameClientVersion = "Client version"
|
|
SessionAttributesDisplayNameJailbreakDetected = "Jailbreak detected"
|
|
SessionAttributesDisplayNameServerFQDN = "Server FQDN"
|
|
SessionAttributesDisplayNameClientFQDN = "Client FQDN"
|
|
SessionAttributesDisplayNameUserAgentPlatform = "User agent platform"
|
|
SessionAttributesDisplayNameUserAgentOS = "User agent OS"
|
|
SessionAttributesDisplayNameUserAgentBrowserName = "User agent browser name"
|
|
SessionAttributesDisplayNameUserAgentBrowserVersion = "User agent browser version"
|
|
SessionAttributesDisplayNameIPAddress = "IP address"
|
|
)
|
|
|
|
const (
|
|
SAAttrEnabled = "enabled"
|
|
SAAttrPlatforms = "platforms"
|
|
SAAttrTTLSeconds = "ttl_seconds"
|
|
SAAttrGracePeriodSeconds = "grace_period_seconds"
|
|
SAAttrDisplayName = "display_name"
|
|
)
|
|
|
|
const (
|
|
SessionAttributeDefaultTTLNetworkIdentity = 15
|
|
SessionAttributeDefaultTTLPosture = 60
|
|
SessionAttributeDefaultTTLIdentity = 300
|
|
)
|
|
|
|
const (
|
|
SessionAttributeDefaultGraceNetworkIdentity = 15
|
|
SessionAttributeDefaultGracePosture = 60
|
|
SessionAttributeDefaultGraceIdentity = 300
|
|
)
|
|
|
|
const (
|
|
SessionAttributeHeaderClientAttributes = "X-MM-Session-Attributes"
|
|
SessionAttributeHeaderProxyDeviceID = "X-Mattermost-Session-Attribute-Device-Id"
|
|
)
|
|
|
|
const (
|
|
SessionOperatorInCIDR = "inCIDR"
|
|
SessionOperatorVersionEQ = "versionEQ"
|
|
SessionOperatorVersionGT = "versionGT"
|
|
SessionOperatorVersionGTE = "versionGTE"
|
|
SessionOperatorVersionLT = "versionLT"
|
|
SessionOperatorVersionLTE = "versionLTE"
|
|
)
|
|
|
|
var sessionStringOperators = []string{"==", "!=", "in", "startsWith", "endsWith", "contains"}
|
|
|
|
var sessionVersionOperators = []string{
|
|
SessionOperatorVersionEQ,
|
|
SessionOperatorVersionGT,
|
|
SessionOperatorVersionGTE,
|
|
SessionOperatorVersionLT,
|
|
SessionOperatorVersionLTE,
|
|
}
|
|
|
|
func sessionOperators(extra ...string) StringInterface {
|
|
return StringInterface{
|
|
NativeAttributeAttrOperators: append(slices.Clone(sessionStringOperators), extra...),
|
|
}
|
|
}
|
|
|
|
var SessionAttributesRequestDerivedFieldNames = map[string]struct{}{
|
|
SessionAttributesPropertyFieldUserAgentPlatform: {},
|
|
SessionAttributesPropertyFieldUserAgentOS: {},
|
|
SessionAttributesPropertyFieldUserAgentBrowserName: {},
|
|
SessionAttributesPropertyFieldUserAgentBrowserVersion: {},
|
|
SessionAttributesPropertyFieldIPAddress: {},
|
|
}
|
|
|
|
var SessionAttributesDeviceIDFieldNames = map[string]struct{}{
|
|
SessionAttributesPropertyFieldTLSDDeviceID: {},
|
|
SessionAttributesPropertyFieldClientDeviceID: {},
|
|
SessionAttributesPropertyFieldHardwareID: {},
|
|
}
|
|
|
|
type SessionAttributesClusterPayload struct {
|
|
SessionID string `json:"session_id"`
|
|
Attrs map[string]any `json:"attrs"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
type SAField struct {
|
|
PropertyField
|
|
Attrs SAAttrs `json:"attrs"`
|
|
}
|
|
|
|
type SAAttrs struct {
|
|
Enabled bool `json:"enabled"`
|
|
Platforms []string `json:"platforms"`
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
GracePeriodSeconds int `json:"grace_period_seconds"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
}
|
|
|
|
type SessionAttributeManifestEntry struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
GracePeriodSeconds int `json:"grace_period_seconds"`
|
|
Platforms []string `json:"platforms"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
}
|
|
|
|
func SAFieldFromPropertyField(field *PropertyField) (*SAField, error) {
|
|
if field == nil {
|
|
return nil, fmt.Errorf("property field is nil")
|
|
}
|
|
|
|
sa := &SAField{PropertyField: *field}
|
|
if len(field.Attrs) == 0 {
|
|
return sa, nil
|
|
}
|
|
|
|
// The only way we can convert the attrs to SAAttrs is to marshal and unmarshal.
|
|
attrsJSON, err := json.Marshal(field.Attrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal session attribute field attrs: %w", err)
|
|
}
|
|
if err := json.Unmarshal(attrsJSON, &sa.Attrs); err != nil {
|
|
return nil, fmt.Errorf("unmarshal session attribute field attrs: %w", err)
|
|
}
|
|
return sa, nil
|
|
}
|
|
|
|
func (f *SAField) EnabledForPlatform(platform string) bool {
|
|
if f == nil || !f.Attrs.Enabled {
|
|
return false
|
|
}
|
|
return slices.Contains(f.Attrs.Platforms, platform)
|
|
}
|
|
|
|
// IsValidSessionAttributeValue checks an incoming session attribute value against the field schema.
|
|
func IsValidSessionAttributeValue(field *PropertyField, value any) bool {
|
|
if field == nil || value == nil {
|
|
return false
|
|
}
|
|
|
|
switch field.Type {
|
|
case PropertyFieldTypeText:
|
|
str, ok := value.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if str == "" {
|
|
return false
|
|
}
|
|
return true
|
|
case PropertyFieldTypeSelect:
|
|
str, ok := value.(string)
|
|
if !ok || str == "" {
|
|
return false
|
|
}
|
|
rawOptions := field.GetAttr(PropertyFieldAttributeOptions)
|
|
if rawOptions == nil {
|
|
return false
|
|
}
|
|
options, err := NewPropertyOptionsFromFieldAttrs[*PluginPropertyOption](rawOptions)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, option := range options {
|
|
if option.GetName() == str || option.GetID() == str {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sessionAttributeFieldAttrs(platforms []string, ttl, grace int) StringInterface {
|
|
return StringInterface{
|
|
SAAttrEnabled: false,
|
|
SAAttrPlatforms: platforms,
|
|
SAAttrTTLSeconds: ttl,
|
|
SAAttrGracePeriodSeconds: grace,
|
|
}
|
|
}
|
|
|
|
func sessionAttributeField(groupID, name, displayName string, fieldType PropertyFieldType, platforms []string, ttl, grace int, extraAttrs StringInterface) *PropertyField {
|
|
attrs := sessionAttributeFieldAttrs(platforms, ttl, grace)
|
|
attrs[SAAttrDisplayName] = displayName
|
|
maps.Copy(attrs, extraAttrs)
|
|
return &PropertyField{
|
|
GroupID: groupID,
|
|
Name: name,
|
|
Type: fieldType,
|
|
ObjectType: PropertyFieldObjectTypeSession,
|
|
TargetType: string(PropertyFieldTargetLevelSystem),
|
|
TargetID: "",
|
|
PermissionField: NewPointer(PermissionLevelSysadmin),
|
|
PermissionValues: NewPointer(PermissionLevelSysadmin),
|
|
PermissionOptions: NewPointer(PermissionLevelSysadmin),
|
|
Attrs: attrs,
|
|
}
|
|
}
|
|
|
|
// SessionAttributeSystemFields returns the built-in session attribute schema fields.
|
|
func SessionAttributeSystemFields(groupID string) []*PropertyField {
|
|
allPlatforms := []string{SessionAttributePlatformDesktop, SessionAttributePlatformMobile, SessionAttributePlatformBrowser}
|
|
clientsOnly := []string{SessionAttributePlatformDesktop, SessionAttributePlatformMobile}
|
|
desktopBrowser := []string{SessionAttributePlatformDesktop, SessionAttributePlatformBrowser}
|
|
desktopOnly := []string{SessionAttributePlatformDesktop}
|
|
mobileOnly := []string{SessionAttributePlatformMobile}
|
|
|
|
boolSelectOptions := StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "true"},
|
|
{"name": "false"},
|
|
},
|
|
}
|
|
|
|
return []*PropertyField{
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldIPAddress, SessionAttributesDisplayNameIPAddress, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, sessionOperators(SessionOperatorInCIDR)),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientIPAddress, SessionAttributesDisplayNameClientIPAddress, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, sessionOperators(SessionOperatorInCIDR)),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldNetworkInterfaceType, SessionAttributesDisplayNameNetworkInterfaceType, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "wifi"},
|
|
{"name": "ethernet"},
|
|
{"name": "cellular"},
|
|
{"name": "vpn"},
|
|
{"name": "bluetooth"},
|
|
{"name": "other"},
|
|
},
|
|
}),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldVPNActive, SessionAttributesDisplayNameVPNActive, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, boolSelectOptions),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldSSID, SessionAttributesDisplayNameSSID, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, nil),
|
|
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldMDMEnrolled, SessionAttributesDisplayNameMDMEnrolled, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, boolSelectOptions),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldJailbreakDetected, SessionAttributesDisplayNameJailbreakDetected, PropertyFieldTypeSelect, mobileOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, boolSelectOptions),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSPlatform, SessionAttributesDisplayNameOSPlatform, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "macos"},
|
|
{"name": "windows"},
|
|
{"name": "linux"},
|
|
{"name": "ios"},
|
|
{"name": "android"},
|
|
},
|
|
}),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSVersion, SessionAttributesDisplayNameOSVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientVersion, SessionAttributesDisplayNameClientVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)),
|
|
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentPlatform, SessionAttributesDisplayNameUserAgentPlatform, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "Windows"},
|
|
{"name": "Macintosh"},
|
|
{"name": "Linux"},
|
|
{"name": "Android"},
|
|
{"name": "iPad"},
|
|
{"name": "iPhone"},
|
|
{"name": "iPod"},
|
|
{"name": "BlackBerry"},
|
|
{"name": "Windows Phone"},
|
|
{"name": "Unknown"},
|
|
},
|
|
}),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentOS, SessionAttributesDisplayNameUserAgentOS, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "Windows"},
|
|
{"name": "Windows 10"},
|
|
{"name": "Windows 8.1"},
|
|
{"name": "Windows 8"},
|
|
{"name": "Windows 7"},
|
|
{"name": "Windows Vista"},
|
|
{"name": "Windows XP x64 Edition"},
|
|
{"name": "Windows XP"},
|
|
{"name": "Windows 2000"},
|
|
{"name": "Windows Phone"},
|
|
{"name": "Mac OS"},
|
|
{"name": "iOS"},
|
|
{"name": "Android"},
|
|
{"name": "Chrome OS"},
|
|
{"name": "Linux"},
|
|
{"name": "BlackBerry"},
|
|
{"name": "Kindle"},
|
|
{"name": "webOS"},
|
|
{"name": "Unknown"},
|
|
},
|
|
}),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserName, SessionAttributesDisplayNameUserAgentBrowserName, PropertyFieldTypeSelect, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, StringInterface{
|
|
PropertyFieldAttributeOptions: []map[string]string{
|
|
{"name": "Chrome"},
|
|
{"name": "Firefox"},
|
|
{"name": "Safari"},
|
|
{"name": "Edge"},
|
|
{"name": "Internet Explorer"},
|
|
{"name": "Opera"},
|
|
{"name": "Android"},
|
|
{"name": "BlackBerry"},
|
|
{"name": "Desktop App"},
|
|
{"name": "Mobile App"},
|
|
{"name": "mmctl"},
|
|
{"name": "Unknown"},
|
|
},
|
|
}),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserVersion, SessionAttributesDisplayNameUserAgentBrowserVersion, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, sessionOperators(sessionVersionOperators...)),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldTLSDDeviceID, SessionAttributesDisplayNameTLSDDeviceID, PropertyFieldTypeText, desktopBrowser, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientDeviceID, SessionAttributesDisplayNameClientDeviceID, PropertyFieldTypeText, mobileOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldHardwareID, SessionAttributesDisplayNameHardwareID, PropertyFieldTypeText, desktopOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldServerFQDN, SessionAttributesDisplayNameServerFQDN, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
|
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientFQDN, SessionAttributesDisplayNameClientFQDN, PropertyFieldTypeText, desktopOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
|
}
|
|
}
|