mirror of
https://github.com/gravitl/netmaker.git
synced 2026-10-12 02:15:06 +00:00
* wip(go): pass ctx during license validation; * wip(go): add todo for making changes later; run hook separately for each tenant; * wip(go): return specific errors when license validation response not in cache; * wip(go): use retryable.httpclient for calling license validation api; * wip(go): process license validation; * wip(go): license validation function; * wip(go): migration on license validation; * wip(go): org settings not expected; * wip(go): remove expiry from license validation response; * wip(go): add limits and limits enforcement to license validation response; * wip(go): remove expiry usages; * wip(go): add arg to clear cache; * wip(go): add arg to clear cache; * wip(go): skip license validation if cached resp; * wip(go): allow org_id and tenant_id query params; * wip(go): upsert tenant membership on sync; * wip(go): pass ctx to delete network roles; * wip(go): create tenant membership on org owner creation on ce; * wip(go): add tenant orchestrator; * wip(go): use tenant orchestrator; * wip(go): add default settings for tenant; * wip(go): make org owner superadmin on all tenants; * wip(go): simplify SyncOrgAndTenants; * wip(go): retry license validation only 3 times * wip(go): store invalid err; * wip(go): add function to check if limits are to be enforced on a tenant; * wip(go): pass tenant scope if possible when checking for license validation error; * wip(go): remove merge errors; * wip(go): add functions for limit checks; * wip(go): wire host limit check; * wip(go): wire ingress limit check; * wip(go): wire egress limit check; * wip(go): wire client limit check; * wip(go): wire network limit check; * wip(go): wire user limit check; * wip(go): use correct errors; * wip(go): skip telemetry for msps; * wip(go): disable telemetry on msp tenants; * wip(go): err check; * wip(go): uncomment log event; * wip(go): get sso state to get scope for non-headless oauth login; * wip(go): inline delete pending user; * wip(go): allow both org and tenant scope for pending users table; * wip(go): add migration for pending user table during set tenant id and rekey org/tenant id; * wip(go): update queries to use new scope fields in pending users table; * wip(go): allow both org and tenant scope for user invites table; * wip(go): update queries to use new scope fields; * wip(go): add migration for rekey-ing org/tenant id and setting tenant id; * wip(go): inline delete user invite; * wip(go): set scope when creating invite; * wip(go): get user count with membership is ctx is tenant scoped; * wip(go): add methods to upsert and delete org memberships; * wip(go): add global valid tenant and org roles map; * wip(go): add scope query in user invite get by email; * wip(go): add support for upsert and delete of org and tenant membership both; * wip(go): infer scope for user invite apis; * wip(go): wire ctx in get user invite by email; * wip(go): allow no scope headers to be passed in infer scope apis; * wip(go): add validation and checks for org-scoped invite api handlers; * wip(go): wire ctx to is pending user check; * wip(go): infer scope for pending user apis; * wip(go): set default role based on ctx scope; * wip(go): move mfa to tenant membership table; move account status to membership tables; * wip(go): migrate account status and mfa settings; * wip(go): update account status on sync; * wip(go): use update mfa to update mfa setting; * wip(go): set new membership settings on user creation; * wip(go): use membership where required; * wip(go): wire ctx on key unlink refs; * wip(go): use r.Context directly without reinitializing db; * wip(go): wire ctx in acls; * wip(go): add tenant scoped primary key constructor; * wip(go): use tenant scoped primary key for acls, dns, extclients and tags; * wip(go): wire ctx for get groups in network; * wip(go): wire ctx for get groups; * wip(go): scope non-uuid group ids by tenant id; * wip(go): migrate non-uuid group ids to scoped ids; * wip(go): don't expect scope id in invite validation and signup apis; * wip(go): wire ctx for get matching user; allow org scope when querying by external idp id; * wip(go): add mfa to org; * wip(go): scope preauth token; * wip(go): add fallback for older auth tokens; * wip(go): wire ctx for preauth token creation; * wip(go): migrate mfa settings; * wip(go): resolve mfa settings for inherited auth; * wip(go): update mfa for org; * wip(go): infer scope for totp apis; * wip(go): use ctx when passed; * wip(go): infer scope for list user apis; * wip(go): ensure set primary keys don't cause errors during fetch; * wip(go): make primary key for integrations uuid and store provider in separate key; * wip(go): migrate integration ids; * wip(go): use provider instead of id everywhere; * wip(go): remove gorm column; * wip(go): wire ctx for igw egress creation; * wip(go): wire ctx for igw egress update; * wip(go): wire ctx for host registration; * wip(go): wire ctx for host registration; * wip(go): merge errors; * wip(go): merge errors; * wip(go): wire ctx; remove default scope usages; * wip(go): use get with membership; * wip(go): org scope license validation; * wip(go): remove debug log; * wip(go): remove scoping on list roles; * wip(go): ignore invite creation if already exists; * wip(go): add is msp func to check if deployment is msp deployment; * wip(go): ensure username is email for msp users; * wip(go): add email validated check on user table; * wip(go): mark email validated when oauth user; * wip(go): handle gorm error; * wip(go): add tenant id to siem grpc schema; * wip(go): read tenant from scoped ctx; * wip(go): pass scoped ctx to siem grpc client; * wip(go): remove unused publisher; * wip(go): publish all tenant exporter updates on pull; * wip(go): fix wg usage; * wip(go): tenant scope the ctx when getting server info; * wip(go): add tenant id to enrollment key token; * wip(go): make host register tenant scope; * wip(go): add default settings; * wip(go): use correct checks for is msp tenant; * wip(go): add email validation extension; * wip(go): add pages for validation check; * wip(go): use extension for validating user; * wip(go): add invite type; * wip(go): create only one validation invite per user email; * wip(go): add validate email handler; * wip(go): add email builder; * wip(go): add user invite type migration logic; * wip(go): only log email send failures; * wip(go): add tenant id to flows table; * wip(go): add func to check if connected to clickhouse; * wip(go): migrate clickhouse; * wip(go): add tenant id to mdm and edr state; * wip(go): add delete methods for tables; * wip(go): add tenant teardown method; * wip(go): handle tenant deletion; * wip(go): clean up flow logs on tenant deletion; * wip(go): add update org membership role id method; * wip(go): add transfer org ownership api; * wip(go): move audit logs retention period to org settings; * wip(go):get retention period from org settings; * wip(go): remove old audit logs usage; * wip(go): migrate audit logs retention period; * wip(go): unmarshal decodes nums as float64; * wip(go): run flow logs cleanup for all tenants in one go; * wip(go): add audit logs retention period and smtp settings to tenant settings table for non-msp tenants; * wip(go): resolve retention period and smtp settings correctly; * wip(go): handle retention period and smtp settings updates and fetches correctly; * wip(go): overlay retention period and smtp settings on server settings;; * wip(go): upsert overlayed settings; * wip(go): add azure tenant id to org settings; * wip(go): tenant scope list roles; * wip(go): add noop creds; * wip(go): use first instead of find; * wip(go): use correct roles when transfering ownership; * wip(go): delete access tokens only if tenant scoped; * wip(go): make delete user work for both org and tenant scope; * wip(go): delete extclients only if tenant scoped; * wip(go): check if caller is allowed to delete user; * wip(go): infer scope for update user; * wip(go): prevent org-owner elevation; * wip(go): permissions check for update; * wip(go): delete user access tokens only if tenant scope; * wip(go): delete extclients only if tenant scope; * wip(go): move upsert membership in upsert user; * wip(go): set api host when constructing server config; * wip(go): fix static errors; * wip(go): fix test cases; * wip(go): use correct get ns order; * wip(go): use correct get pending host order; * wip(go): use correct get access token order; * wip(go): use correct get event order; * wip(go): return error response on emqx user creation error; * wip(go): store correct value in cache; * wip(go): return better error response; * wip(go): return better error response; * wip(go): use cache when listing egress acls; * wip(go): wire ctx in UserHasNetworkGroupAccess; * wip(go): remove double scope wrapping; * wip(go): add security check for list and get org; * wip(go): ratelimit discover api; * wip(go): expect sole org id in get and create org owner; * wip(go): wrap create org owner in tx; * wip(go): wire ctx correctly; * wip(go): wire ctx for posture check functions; * wip(go): remove resolved todo; * wip(go): wire ctx for posture check function; * wip(go): remove scope from netdesk login api; * wip(go): use orgid correctly; * wip(go): scope user update checks correctly; * wip(go): wire ctx for fetch oauth secret; * wip(go): add tenant_id and org_id query string param to user invite url; * wip(go): get sole org; * wip(go): set query string correctly; * wip(go): wire ctx in update user groups; * wip(go): allow delete oauth non-idp users; * wip(go): delete access tokens post update but check before update; * wip(go): ignore h.Nodes on create host; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): create tenant membership for org user directly; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): get user with membership on callback; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): use write locks; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): prevent create, update, delete of roles; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): ensure role belongs to network we are checking permission for; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): handle error on invite creation correctly; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): handle invite not found on org scoped oauth callback; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): init default groups on tenant creation; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): upset org and tenant on change; * wip(go): use GetPlatformRole method to get platform role; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): created default network roles on tenant creation; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): use GetAnyRole when unsure; remove unused funcs; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): initialize network roles on startup; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): use GetNetworkRole method to get network role; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): migrate ids of all network roles; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): check both scoped and unscoped on delete; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): check both scoped and unscoped on update; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): scope id on network role upsert; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): add methods to fetch platform and network roles; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): scope network role id on create; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): add methods to scope and unscope role ids; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): remove uuid check; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): list scoped network roles; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): remove unuse update method; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): use correct id on get and delete; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): allow all_networks role in permission access; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): scope ctx from state cache; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): remove create, delete and update roles from netmaker cli; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): create network admin and user with ctx; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): allow updating names and metadata; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): remove tenant-scoping from headless sso apis; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): check for status code before unmarshalling; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): allow access to discover api on license validation error; Signed-off-by: VishalDalwadi <[email protected]> * wip(go): use tx; Signed-off-by: VishalDalwadi <[email protected]> --------- Signed-off-by: VishalDalwadi <[email protected]>
316 lines
9.0 KiB
Go
316 lines
9.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gravitl/netmaker/logger"
|
|
"github.com/gravitl/netmaker/logic"
|
|
"github.com/gravitl/netmaker/models"
|
|
"github.com/gravitl/netmaker/orchestrator"
|
|
proLogic "github.com/gravitl/netmaker/pro/logic"
|
|
"github.com/gravitl/netmaker/schema"
|
|
"github.com/gravitl/netmaker/scope"
|
|
"github.com/gravitl/netmaker/servercfg"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/github"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// GitHubProvider implements Provider for GitHub OAuth2.
|
|
type GitHubProvider struct {
|
|
cfg *oauth2.Config
|
|
}
|
|
|
|
// NewGitHubProvider constructs a GitHubProvider for the given OAuth2 credentials.
|
|
func NewGitHubProvider(redirectURL, clientID, clientSecret string) *GitHubProvider {
|
|
return &GitHubProvider{
|
|
cfg: &oauth2.Config{
|
|
RedirectURL: redirectURL,
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
Scopes: []string{"read:user", "user:email"},
|
|
Endpoint: github.Endpoint,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *GitHubProvider) Name() string {
|
|
return github_provider_name
|
|
}
|
|
|
|
func (p *GitHubProvider) Config() *oauth2.Config {
|
|
return p.cfg
|
|
}
|
|
|
|
func (p *GitHubProvider) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
|
appName := r.Header.Get("X-Application-Name")
|
|
if appName == "" {
|
|
appName = logic.NetmakerDesktopApp
|
|
}
|
|
|
|
oauthState := logic.RandomString(user_signin_length)
|
|
err := logic.SetState(scope.Level(r.Context()), scope.ID(r.Context()), appName, oauthState)
|
|
if err != nil {
|
|
handleSomethingWentWrong(w)
|
|
return
|
|
}
|
|
|
|
url := p.cfg.AuthCodeURL(oauthState)
|
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
func (p *GitHubProvider) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|
var rState, rCode = getStateAndCode(r)
|
|
|
|
state, err := logic.GetState(rState)
|
|
if err != nil {
|
|
handleOauthNotValid(w)
|
|
return
|
|
}
|
|
|
|
content, err := p.GetUserInfo(rState, rCode)
|
|
if err != nil {
|
|
logger.Log(1, "error when getting user info from github:", err.Error())
|
|
if strings.Contains(err.Error(), "invalid oauth state") || strings.Contains(err.Error(), "failed to fetch user email from SSO state") {
|
|
handleOauthNotValid(w)
|
|
return
|
|
}
|
|
handleOauthNotConfigured(w)
|
|
return
|
|
}
|
|
|
|
var inviteExists bool
|
|
in, err := logic.GetUserInvite(r.Context(), content.Email)
|
|
if err == nil {
|
|
inviteExists = true
|
|
}
|
|
if !inviteExists && logic.IsPendingUser(r.Context(), content.Email) {
|
|
handleOauthUserSignUpApprovalPending(w)
|
|
return
|
|
}
|
|
|
|
// if user exists with provider login ID, migrate them to email-based username
|
|
user := &schema.User{Username: content.Login}
|
|
err = user.GetWithMembership(r.Context())
|
|
if err == nil {
|
|
if user.AuthType == schema.BasicAuth {
|
|
logger.Log(0, "invalid auth type: basic_auth")
|
|
handleAuthTypeMismatch(w)
|
|
return
|
|
}
|
|
emailCheck := &schema.User{Username: content.Email}
|
|
err = emailCheck.Get(r.Context())
|
|
if err != nil {
|
|
user.Username = content.Email
|
|
user.ExternalIdentityProviderID = content.Login
|
|
user.EmailValidated = true
|
|
_ = user.Update(r.Context())
|
|
}
|
|
}
|
|
|
|
emailCheck := &schema.User{Username: content.Email}
|
|
err = emailCheck.GetWithMembership(r.Context())
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
if inviteExists {
|
|
user, err := proLogic.PrepareOauthUserFromInvite(r.Context(), in)
|
|
if err != nil {
|
|
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
|
|
return
|
|
}
|
|
user.ExternalIdentityProviderID = string(content.ID)
|
|
err = orchestrator.GetRepository().UserOrchestrator().CreateUser(r.Context(), &user)
|
|
if err != nil {
|
|
if errors.Is(err, logic.ErrUserLimitExceeded) {
|
|
handleUserLimitExceeded(w)
|
|
return
|
|
}
|
|
handleSomethingWentWrong(w)
|
|
return
|
|
}
|
|
_ = (&schema.UserInvite{
|
|
Email: content.Email,
|
|
}).DeleteByEmail(r.Context())
|
|
|
|
_ = (&schema.PendingUser{
|
|
Username: content.Email,
|
|
}).Delete(r.Context())
|
|
} else {
|
|
if scope.Level(r.Context()) == scope.OrgScope {
|
|
handleOauthInviteNotFound(w)
|
|
return
|
|
}
|
|
|
|
if !isEmailAllowed(r.Context(), content.Email) {
|
|
handleOauthUserNotAllowedToSignUp(w)
|
|
return
|
|
}
|
|
pendingUser := &schema.PendingUser{
|
|
Scope: scope.Level(r.Context()),
|
|
ScopeID: scope.ID(r.Context()),
|
|
Username: content.Email,
|
|
ExternalIdentityProviderID: string(content.ID),
|
|
}
|
|
err = pendingUser.Create(r.Context())
|
|
if err != nil {
|
|
handleSomethingWentWrong(w)
|
|
return
|
|
}
|
|
handleFirstTimeOauthUserSignUp(w)
|
|
return
|
|
}
|
|
} else {
|
|
handleSomethingWentWrong(w)
|
|
return
|
|
}
|
|
}
|
|
|
|
user = &schema.User{Username: content.Email}
|
|
err = user.GetWithMembership(r.Context())
|
|
if err != nil {
|
|
handleOauthUserNotFound(w)
|
|
return
|
|
}
|
|
|
|
if user.AccountDisabled {
|
|
handleUserAccountDisabled(w)
|
|
return
|
|
}
|
|
|
|
userRole := &schema.UserRole{ID: user.PlatformRoleID}
|
|
err = userRole.GetPlatformRole(r.Context())
|
|
if err != nil {
|
|
handleSomethingWentWrong(w)
|
|
return
|
|
}
|
|
if userRole.DenyDashboardAccess {
|
|
handleOauthUserNotAllowed(w)
|
|
return
|
|
}
|
|
|
|
newPass, fetchErr := logic.FetchOAuthSecret(r.Context())
|
|
if fetchErr != nil {
|
|
return
|
|
}
|
|
authRequest := models.UserAuthParams{
|
|
UserName: content.Email,
|
|
Password: newPass,
|
|
}
|
|
jwt, jwtErr := logic.VerifyAuthRequest(r.Context(), authRequest, state.AppName)
|
|
if jwtErr != nil {
|
|
logger.Log(1, "could not parse jwt for user", authRequest.UserName)
|
|
return
|
|
}
|
|
|
|
logic.LogEvent(r.Context(), &models.Event{
|
|
Action: schema.Login,
|
|
Source: models.Subject{
|
|
ID: user.Username,
|
|
Name: user.Username,
|
|
Type: schema.UserSub,
|
|
},
|
|
TriggeredBy: user.Username,
|
|
Target: models.Subject{
|
|
ID: schema.DashboardSub.String(),
|
|
Name: schema.DashboardSub.String(),
|
|
Type: schema.DashboardSub,
|
|
Info: logic.ToReturnUser(user),
|
|
},
|
|
Origin: schema.Dashboard,
|
|
})
|
|
logger.Log(1, "completed github OAuth signin for", content.Email)
|
|
http.Redirect(w, r, servercfg.GetFrontendURL()+"/login?login="+jwt+"&user="+content.Email, http.StatusPermanentRedirect)
|
|
}
|
|
|
|
func (p *GitHubProvider) GetUserInfo(state, code string) (*OAuthUser, error) {
|
|
oauth_state_string, isValid := logic.IsStateValid(state)
|
|
if (!isValid || state != oauth_state_string) && !isStateCached(state) {
|
|
return nil, fmt.Errorf("invalid oauth state")
|
|
}
|
|
token, err := p.cfg.Exchange(context.Background(), code, oauth2.SetAuthURLParam("prompt", "login"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("code exchange failed: %s", err.Error())
|
|
}
|
|
if !token.Valid() {
|
|
return nil, fmt.Errorf("GitHub code exchange yielded invalid token")
|
|
}
|
|
data, err := json.Marshal(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert token to json: %s", err.Error())
|
|
}
|
|
httpReq, reqErr := http.NewRequest("GET", "https://api.github.com/user", nil)
|
|
if reqErr != nil {
|
|
return nil, fmt.Errorf("failed to create request to GitHub")
|
|
}
|
|
httpReq.Header.Set("Authorization", "token "+token.AccessToken)
|
|
response, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed getting user info: %s", err.Error())
|
|
}
|
|
defer response.Body.Close()
|
|
contents, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed reading response body: %s", err.Error())
|
|
}
|
|
userInfo := &OAuthUser{}
|
|
if err = json.Unmarshal(contents, userInfo); err != nil {
|
|
return nil, fmt.Errorf("failed parsing email from response data: %s", err.Error())
|
|
}
|
|
userInfo.AccessToken = string(data)
|
|
if userInfo.Email == "" {
|
|
logger.Log(2, "fetching user email from github api")
|
|
userInfo.Email, err = getGithubEmailsInfo(token.AccessToken)
|
|
if err != nil {
|
|
logger.Log(0, "failed to fetch user's email from github: ", err.Error())
|
|
}
|
|
}
|
|
if userInfo.Email == "" {
|
|
return userInfo, errors.New("failed to fetch user email from SSO state")
|
|
}
|
|
return userInfo, nil
|
|
}
|
|
|
|
func getGithubEmailsInfo(accessToken string) (string, error) {
|
|
httpReq, reqErr := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
|
if reqErr != nil {
|
|
return "", fmt.Errorf("failed to create request to GitHub")
|
|
}
|
|
httpReq.Header.Add("Accept", "application/vnd.github.v3+json")
|
|
httpReq.Header.Set("Authorization", "token "+accessToken)
|
|
response, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed getting user info: %s", err.Error())
|
|
}
|
|
defer response.Body.Close()
|
|
contents, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed reading response body: %s", err.Error())
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
var errInfo struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if jsonErr := json.Unmarshal(contents, &errInfo); jsonErr == nil && errInfo.Message != "" {
|
|
return "", fmt.Errorf("failed getting user emails from GitHub: %s", errInfo.Message)
|
|
}
|
|
return "", fmt.Errorf("failed getting user emails from GitHub: status %d", response.StatusCode)
|
|
}
|
|
var emailsInfo []interface{}
|
|
if err = json.Unmarshal(contents, &emailsInfo); err != nil {
|
|
return "", err
|
|
}
|
|
for _, info := range emailsInfo {
|
|
emailInfoMap := info.(map[string]interface{})
|
|
if emailInfoMap["primary"].(bool) {
|
|
return emailInfoMap["email"].(string), nil
|
|
}
|
|
}
|
|
return "", errors.New("email not found")
|
|
}
|