diff --git a/.gitignore b/.gitignore index a361b52..171f672 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,6 @@ linux-amd64-nextzenos-user-service-v1.2.4.tar.gz dist/casaos-user-service-amd64_linux_amd64_v1/build/sysroot/usr/bin/casaos-user-service dist/casaos-user-service-amd64_linux_amd64_v1/build/sysroot/usr/bin/casaos-user-service dist/casaos-user-service-amd64_linux_amd64_v1/build/sysroot/usr/bin/casaos-user-service - +.env .vscode/launch.json dist/metadata.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a25dd6..9c432bf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "go", "debugAdapter": "dlv-dap", "request": "launch", - "port": 33107, + "port": 40131, "host": "127.0.0.1", "mode": "exec", "program": "${workspaceFolder}/dist/casaos-user-service-amd64_linux_amd64_v1/build/sysroot/usr/bin/casaos-user-service" diff --git a/go.mod b/go.mod index 093cc44..ca26575 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/deepmap/oapi-codegen v1.12.4 github.com/getkin/kin-openapi v0.117.0 github.com/gin-contrib/gzip v0.0.6 + github.com/gin-contrib/sessions v1.0.1 github.com/gin-gonic/gin v1.10.0 github.com/glebarez/sqlite v1.8.0 github.com/labstack/echo/v4 v4.12.0 @@ -48,7 +49,10 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.2.2 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 3fd94e1..715c929 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/getkin/kin-openapi v0.117.0 h1:QT2DyGujAL09F4NrKDHJGsUoIprlIcFVHWDVDc github.com/getkin/kin-openapi v0.117.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI= +github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= @@ -80,12 +82,20 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= diff --git a/main.go b/main.go index 76d0835..f26786e 100644 --- a/main.go +++ b/main.go @@ -125,6 +125,7 @@ func main() { apiPaths := []string{ "/v1/users", + "/v1/1panel", route.V2APIPath, route.V2DocPath, "/" + jwt.JWKSPath, diff --git a/route/v1.go b/route/v1.go index 40e6949..fa88d4b 100644 --- a/route/v1.go +++ b/route/v1.go @@ -9,6 +9,8 @@ import ( v1 "github.com/KaySar12/NextZen-UserService/route/v1" "github.com/KaySar12/NextZen-UserService/service" "github.com/gin-contrib/gzip" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" ) @@ -18,12 +20,16 @@ func InitRouter() *gin.Engine { r.Use(v1.CheckOIDCInit()) r.Use(gzip.Gzip(gzip.DefaultCompression)) + store := cookie.NewStore([]byte("secret")) + sessionMiddleware := sessions.Sessions("1Panel", store) + r.Use(sessionMiddleware) // check if environment variable is set if ginMode, success := os.LookupEnv("GIN_MODE"); success { gin.SetMode(ginMode) } else { gin.SetMode(gin.ReleaseMode) } + go v1.InitOIDC() r.POST("/v1/users/register", v1.PostUserRegister) r.POST("/v1/users/login", v1.PostUserLogin) @@ -43,8 +49,11 @@ func InitRouter() *gin.Engine { r.GET("/v1/users/oidc/health", v1.OIDCHealthCheck) r.GET("/v1/users/oidc/settings", v1.GetOIDCSettings) r.POST("/v1/users/oidc/saveSettings", v1.SaveOIDCSettings) - r.GET("/v1/users/1panel/health", v1.OnePanelHealthCheck) - r.POST("/v1/users/1panel/login", v1.OnePanelLogin) + r.GET("/v1/1panel/health", v1.OnePanelHealthCheck) + // r.POST("/v1/1panel/login", v1.OnePanelLogin) + // r.POST("/v1/1panel/app/search", v1.ExternalAPIMiddleware, v1.OnePanelLogin) + // r.POST("/v1/1panel/website/search", v1.ExternalAPIMiddleware, v1.OnePanelLogin) + r.POST("/v1/1panel/website/create", v1.ExternalAPIMiddleware, v1.OnePanelCreateWebsite) v1Group := r.Group("/v1") v1Group.Use(jwt.JWT( @@ -54,6 +63,15 @@ func InitRouter() *gin.Engine { }, )) { + // v1OnePanel := v1Group.Group("/1panel") + // v1OnePanel.Use() + // { + // r.GET("/health", v1.OnePanelHealthCheck) + // r.POST("/login", v1.OnePanelLogin) + // r.POST("/app/search", v1.ExternalAPIMiddleware, v1.OnePanelLogin) + // r.POST("/website/search", v1.ExternalAPIMiddleware, v1.OnePanelLogin) + // r.POST("/website/create", v1.ExternalAPIMiddleware, v1.OnePanelCreateWebsite) + // } v1UsersGroup := v1Group.Group("/users") v1UsersGroup.Use() { diff --git a/route/v1/user.go b/route/v1/user.go index 87a31bc..6a6cc5d 100644 --- a/route/v1/user.go +++ b/route/v1/user.go @@ -36,6 +36,7 @@ import ( "github.com/KaySar12/NextZen-UserService/service" model2 "github.com/KaySar12/NextZen-UserService/service/model" "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" uuid "github.com/satori/go.uuid" "github.com/tidwall/gjson" @@ -45,15 +46,12 @@ import ( ) var ( - //authServer = "http://10.0.0.26:9000" - authServer = "http://accessmanager.local" - clientID = "6KwKSxLCtaQ4r6HoAn3gdNMbNOAf75j3SejLIAx7" - clientSecret = "PE05fcDP4qESUmyZ1TNYpZNBxRPq70VpFI81vehsoJ6WhGz5yPXMljrFrOdMRdRhrYmF03fHWTZHgO9ZdNENrLN13BzL8CAgtEkTsyjXfgx9GvISheIjYfpSfvo219fL" - authURL = "http://accessmanager.local/application/o/nextzenos-oidc/" - //authURL = "http://10.0.0.26:9000/application/o/nextzenos-oidc/" - callbackURL = "http://nextzenos.local/v1/users/oidc/callback" - //callbackURL = "http://172.20.60.244:8080/v1/users/oidc/callback" - onePanelServer = "http://172.20.60.244:13000" + authServer = "http://accessmanager.local" + clientID = "6KwKSxLCtaQ4r6HoAn3gdNMbNOAf75j3SejLIAx7" + clientSecret = "PE05fcDP4qESUmyZ1TNYpZNBxRPq70VpFI81vehsoJ6WhGz5yPXMljrFrOdMRdRhrYmF03fHWTZHgO9ZdNENrLN13BzL8CAgtEkTsyjXfgx9GvISheIjYfpSfvo219fL" + authURL = "http://accessmanager.local/application/o/nextzenos-oidc/" + callbackURL = "http://nextzenos.local/v1/users/oidc/callback" + onePanelServer = "http://nextweb.local" onePanelName = "nextzen" onePanelPassword = "Smartyourlife123@*" ) @@ -117,16 +115,36 @@ func PostUserRegister(c *gin.Context) { var limiter = rate.NewLimiter(rate.Every(time.Minute), 5) -// @Summary login -// @Produce application/json -// @Accept application/json -// @Tags user -// @Param user_name query string true "User name" -// @Param pwd query string true "password" -// @Success 200 {string} string "ok" -// @Router /user/login [post] -func OnePanelLogin(c *gin.Context) { - var cred = model2.OnePanelCredentials{ +func ExternalAPIMiddleware(c *gin.Context) { + session := sessions.Default(c) + sessionId := session.Get("psession") + + if sessionId == nil { + if err := OnePanelLogin(c); err != nil { + c.JSON(http.StatusUnauthorized, model.Result{ + Success: common_err.SERVICE_ERROR, + Message: common_err.GetMsg(common_err.SERVICE_ERROR), + }) + c.Abort() + return + } + sessionId = session.Get("psession") + if sessionId == nil { + c.JSON(http.StatusInternalServerError, model.Result{ + Success: common_err.SERVICE_ERROR, + Message: common_err.GetMsg(common_err.SERVICE_ERROR), + }) + c.Abort() + return + } + } + + // Add sessionId to the request's Cookie header + c.Request.Header.Set("Cookie", "psession="+sessionId.(string)) + c.Next() +} +func OnePanelLogin(c *gin.Context) error { + cred := model2.OnePanelCredentials{ Name: onePanelName, Password: onePanelPassword, IgnoreCaptcha: true, @@ -135,7 +153,94 @@ func OnePanelLogin(c *gin.Context) { AuthMethod: "session", Language: "en", } - response, cookies, err := service.MyService.OnePanel().Login(cred, "http://172.20.60.244:13000") + + response, cookies, err := service.MyService.OnePanel().Login(cred, onePanelServer) + fmt.Println(response) + if err != nil { + logger.Error("OnePanel login failed", zap.Error(err)) + return err + } + + session := sessions.Default(c) + for _, cookie := range cookies { + session.Set(cookie.Name, cookie.Value) + } + + if err := session.Save(); err != nil { + logger.Error("Failed to save session", zap.Error(err)) + return err + } + + return nil +} + +// func OnePanelLogin(c *gin.Context) { +// var cred = model2.OnePanelCredentials{ +// Name: onePanelName, +// Password: onePanelPassword, +// IgnoreCaptcha: true, +// Captcha: "", +// CaptchaID: "", +// AuthMethod: "session", +// Language: "en", +// } + +// response, cookies, err := service.MyService.OnePanel().Login(cred, onePanelServer) +// if err != nil { +// c.JSON(common_err.SERVICE_ERROR, +// model.Result{ +// Success: common_err.SERVICE_ERROR, +// Message: common_err.GetMsg(common_err.SERVICE_ERROR), +// }) +// } +// session := sessions.Default(c) +// for _, cookie := range cookies { +// session.Set(cookie.Name, cookie.Value) +// c.SetCookie(cookie.Name, cookie.Value, 3600, "/", "", false, true) +// } +// session.Save() +// c.JSON(common_err.SUCCESS, +// model.Result{ +// Success: common_err.SUCCESS, +// Message: common_err.GetMsg(common_err.SUCCESS), +// Data: response, +// }) +// } +func OnePanelCreateWebsite(c *gin.Context) { + json := make(map[string]string) + c.ShouldBind(&json) + domain := json["domain"] + port := json["port"] + protocol := json["protocol"] + // useSSl := json["useSSL"] + var website model2.CreateWebsiteRequest + website.PrimaryDomain = domain + website.Type = "proxy" + website.Alias = domain + website.AppType = "installed" + website.WebSiteGroupID = 2 + website.Proxy = "http://127.0.0.1:" + port + portInt, err := strconv.ParseInt(port, 10, 64) + if err != nil { + log.Printf("Error converting port to integer: %v", err) + + } + website.Port = portInt + website.ProxyProtocol = protocol + website.ProxyAddress = "127.0.0.1" + port + website.RuntimeType = "php" + headers := make(map[string]string) + for key, value := range c.Request.Header { + headers[key] = value[0] + } + var searchParam model2.SearchWebsiteRequest + searchParam.Name = website.PrimaryDomain + searchParam.Page = 1 + searchParam.PageSize = 1 + searchParam.OrderBy = "created_at" + searchParam.Order = "null" + searchParam.WebsiteGroupID = 0 + search, err := service.MyService.OnePanel().SearchWebsite(searchParam, onePanelServer, headers) if err != nil { c.JSON(common_err.SERVICE_ERROR, model.Result{ @@ -143,14 +248,26 @@ func OnePanelLogin(c *gin.Context) { Message: common_err.GetMsg(common_err.SERVICE_ERROR), }) } - for _, cookie := range cookies { - c.SetCookie(cookie.Name, cookie.Value, 3600, "/", "", false, true) + if search.Data.Total == 0 { + response, err := service.MyService.OnePanel().CreateWebsite(website, onePanelServer, headers) + if err != nil { + c.JSON(common_err.SERVICE_ERROR, + model.Result{ + Success: common_err.SERVICE_ERROR, + Message: common_err.GetMsg(common_err.SERVICE_ERROR), + }) + } + c.JSON(common_err.SUCCESS, + model.Result{ + Success: common_err.SUCCESS, + Message: common_err.GetMsg(common_err.SUCCESS), + Data: response, + }) } c.JSON(common_err.SUCCESS, model.Result{ Success: common_err.SUCCESS, Message: common_err.GetMsg(common_err.SUCCESS), - Data: response, }) } func PostUserLogin(c *gin.Context) { diff --git a/service/1panel.go b/service/1panel.go index e407613..9061841 100644 --- a/service/1panel.go +++ b/service/1panel.go @@ -16,8 +16,8 @@ type OnePanelService interface { HealthCheck(baseURL string) (string, error) SearchInstalledApp(p model2.InstalledAppRequest, baseURL string) (model2.InstalledAppResponse, error) // InstallApp() - // SearchWebsite() - // CreateWebsite() + SearchWebsite(m model2.SearchWebsiteRequest, baseUrl string, headers map[string]string) (model2.SearchWebsiteResponse, error) + CreateWebsite(m model2.CreateWebsiteRequest, baseUrl string, headers map[string]string) (model2.GenericResponse, error) // DeleteWebsite() } @@ -28,6 +28,65 @@ var ( type onePanelService struct { } +func (o *onePanelService) SearchWebsite(m model2.SearchWebsiteRequest, baseUrl string, headers map[string]string) (model2.SearchWebsiteResponse, error) { + path := baseUrl + "/api/v1/websites/search" + reqBody, err := json.Marshal(m) + if err != nil { + return model2.SearchWebsiteResponse{}, fmt.Errorf("error marshaling request body: %v", err) + } + req, err := http.NewRequest("POST", path, bytes.NewReader(reqBody)) + if err != nil { + return model2.SearchWebsiteResponse{}, fmt.Errorf("error creating request: %v", err) + } + // Add headers to the request + for key, value := range headers { + req.Header.Set(key, value) + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return model2.SearchWebsiteResponse{}, fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return model2.SearchWebsiteResponse{}, fmt.Errorf("HTTP error: %s", resp.Status) + } + var result model2.SearchWebsiteResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return model2.SearchWebsiteResponse{}, fmt.Errorf("error decoding response: %v", err) + } + return result, nil +} +func (o *onePanelService) CreateWebsite(m model2.CreateWebsiteRequest, baseUrl string, headers map[string]string) (model2.GenericResponse, error) { + path := baseUrl + "/api/v1/websites" + reqBody, err := json.Marshal(m) + if err != nil { + return model2.GenericResponse{}, fmt.Errorf("error marshaling request body: %v", err) + } + req, err := http.NewRequest("POST", path, bytes.NewReader(reqBody)) + if err != nil { + return model2.GenericResponse{}, fmt.Errorf("error creating request: %v", err) + } + // Add headers to the request + for key, value := range headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return model2.GenericResponse{}, fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return model2.GenericResponse{}, fmt.Errorf("HTTP error: %s", resp.Status) + } + var result model2.GenericResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return model2.GenericResponse{}, fmt.Errorf("error decoding response: %v", err) + } + return result, nil +} func (o *onePanelService) SearchInstalledApp(m model2.InstalledAppRequest, baseUrl string) (model2.InstalledAppResponse, error) { path := baseUrl + "/api/v1/websites/search" reqBody, err := json.Marshal(m) diff --git a/service/model/dtop_onepanel_website.go b/service/model/dtop_onepanel_website.go index bb5fa26..8b65d9f 100644 --- a/service/model/dtop_onepanel_website.go +++ b/service/model/dtop_onepanel_website.go @@ -39,37 +39,36 @@ type SearchWebsiteResponse struct { type CreateWebsiteRequest struct { PrimaryDomain string `json:"primaryDomain"` - Type string `json:"type"` - Alias string `json:"alias"` - Remark string `json:"remark"` - AppType string `json:"appType"` - WebSiteGroupID int `json:"webSiteGroupId"` - OtherDomains string `json:"otherDomains"` - Proxy string `json:"proxy"` + Type string `json:"type,omitempty"` + Alias string `json:"alias,omitempty"` + Remark string `json:"remark,omitempty"` + AppType string `json:"appType,omitempty"` + WebSiteGroupID int64 `json:"webSiteGroupId,omitempty"` + OtherDomains string `json:"otherDomains,omitempty"` + Proxy string `json:"proxy,omitempty"` Appinstall struct { - AppID int `json:"appId"` - Name string `json:"name"` - AppDetailID int `json:"appDetailId"` - Params struct { - } `json:"params"` - Version string `json:"version"` - Appkey string `json:"appkey"` - Advanced bool `json:"advanced"` - CPUQuota int `json:"cpuQuota"` - MemoryLimit int `json:"memoryLimit"` - MemoryUnit string `json:"memoryUnit"` - ContainerName string `json:"containerName"` - AllowPort bool `json:"allowPort"` - } `json:"appinstall"` - IPV6 bool `json:"IPV6"` - EnableFtp bool `json:"enableFtp"` - FtpUser string `json:"ftpUser"` - FtpPassword string `json:"ftpPassword"` - ProxyType string `json:"proxyType"` - Port int `json:"port"` - ProxyProtocol string `json:"proxyProtocol"` - ProxyAddress string `json:"proxyAddress"` - RuntimeType string `json:"runtimeType"` + AppID int64 `json:"appId,omitempty"` + Name string `json:"name,omitempty"` + AppDetailID int64 `json:"appDetailId,omitempty"` + Params struct{} `json:"params,omitempty"` + Version string `json:"version,omitempty"` + Appkey string `json:"appkey,omitempty"` + Advanced bool `json:"advanced,omitempty"` + CPUQuota int64 `json:"cpuQuota,omitempty"` + MemoryLimit int64 `json:"memoryLimit,omitempty"` + MemoryUnit string `json:"memoryUnit,omitempty"` + ContainerName string `json:"containerName,omitempty"` + AllowPort bool `json:"allowPort,omitempty"` + } `json:"appinstall,omitempty"` + IPV6 bool `json:"IPV6,omitempty"` + EnableFtp bool `json:"enableFtp,omitempty"` + FtpUser string `json:"ftpUser,omitempty"` + FtpPassword string `json:"ftpPassword,omitempty"` + ProxyType string `json:"proxyType,omitempty"` + Port int64 `json:"port,omitempty"` + ProxyProtocol string `json:"proxyProtocol,omitempty"` + ProxyAddress string `json:"proxyAddress,omitempty"` + RuntimeType string `json:"runtimeType,omitempty"` } type DeleteWebsiteRequest struct {