diff --git a/LstWrapper/Program.cs b/LstWrapper/Program.cs index 1fd8bed..44a9831 100644 --- a/LstWrapper/Program.cs +++ b/LstWrapper/Program.cs @@ -1,65 +1,149 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; + var builder = WebApplication.CreateBuilder(args); -// Configure clients -builder.Services.AddHttpClient("GoBackend", client => { +builder.Services.AddHttpClient("GoBackend", client => +{ client.BaseAddress = new Uri("http://localhost:8080"); }); var app = builder.Build(); -// Handle trailing slash redirects -app.Use(async (context, next) => { - if (context.Request.Path.Equals("/lst", StringComparison.OrdinalIgnoreCase)) { - context.Response.Redirect("/lst/", permanent: true); - return; +// Enable WebSocket support +app.UseWebSockets(); + +app.Use(async (context, next) => +{ + // Proxy WebSocket requests for /lst/api/logger/logs (adjust path as needed) + if (context.WebSockets.IsWebSocketRequest && + context.Request.Path.StartsWithSegments("/lst/api/logger/logs")) + { + try + { + var backendUri = new UriBuilder("ws", "localhost", 8080) + { + Path = context.Request.Path, + Query = context.Request.QueryString.ToString() + }.Uri; + + using var backendSocket = new ClientWebSocket(); + + // Forward most headers except those managed by WebSocket protocol + foreach (var header in context.Request.Headers) + { + if (!header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) && + !header.Key.Equals("Upgrade", StringComparison.OrdinalIgnoreCase) && + !header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) && + !header.Key.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase) && + !header.Key.Equals("Sec-WebSocket-Version", StringComparison.OrdinalIgnoreCase)) + { + backendSocket.Options.SetRequestHeader(header.Key, header.Value); + } + } + + await backendSocket.ConnectAsync(backendUri, context.RequestAborted); + + using var frontendSocket = await context.WebSockets.AcceptWebSocketAsync(); + + var cts = new CancellationTokenSource(); + + // Bidirectional forwarding tasks + var forwardToBackend = ForwardWebSocketAsync(frontendSocket, backendSocket, cts.Token); + var forwardToFrontend = ForwardWebSocketAsync(backendSocket, frontendSocket, cts.Token); + + await Task.WhenAny(forwardToBackend, forwardToFrontend); + cts.Cancel(); + return; + } + catch (Exception ex) + { + context.Response.StatusCode = (int)HttpStatusCode.BadGateway; + await context.Response.WriteAsync($"WebSocket proxy error: {ex.Message}"); + return; + } } + await next(); }); -// Proxy all requests to Go backend -app.Use(async (context, next) => { - // Skip special paths - if (context.Request.Path.StartsWithSegments("/.well-known")) { +// Proxy normal HTTP requests +app.Use(async (context, next) => +{ + if (context.WebSockets.IsWebSocketRequest) + { await next(); return; } - var client = context.RequestServices.GetRequiredService() - .CreateClient("GoBackend"); - - try { - var request = new HttpRequestMessage( - new HttpMethod(context.Request.Method), + var client = context.RequestServices.GetRequiredService().CreateClient("GoBackend"); + + try + { + var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), context.Request.Path + context.Request.QueryString); - // Copy headers - foreach (var header in context.Request.Headers) { - if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) { + foreach (var header in context.Request.Headers) + { + if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) + { request.Content ??= new StreamContent(context.Request.Body); request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } } - if (context.Request.ContentLength > 0) { + if (context.Request.ContentLength > 0 && request.Content == null) + { request.Content = new StreamContent(context.Request.Body); } - var response = await client.SendAsync(request); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + context.Response.StatusCode = (int)response.StatusCode; - - foreach (var header in response.Headers) { + + foreach (var header in response.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + foreach (var header in response.Content.Headers) + { context.Response.Headers[header.Key] = header.Value.ToArray(); } - if (response.Content.Headers.ContentType != null) { - context.Response.ContentType = response.Content.Headers.ContentType.ToString(); - } + context.Response.Headers.Remove("transfer-encoding"); await response.Content.CopyToAsync(context.Response.Body); } - catch (HttpRequestException) { - context.Response.StatusCode = 502; + catch (HttpRequestException ex) + { + context.Response.StatusCode = (int)HttpStatusCode.BadGateway; + await context.Response.WriteAsync($"Backend request failed: {ex.Message}"); } }); -app.Run(); \ No newline at end of file +async Task ForwardWebSocketAsync(WebSocket source, WebSocket destination, CancellationToken cancellationToken) +{ + var buffer = new byte[4 * 1024]; + try + { + while (source.State == WebSocketState.Open && + destination.State == WebSocketState.Open && + !cancellationToken.IsCancellationRequested) + { + var result = await source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + await destination.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", cancellationToken); + break; + } + await destination.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken); + } + } + catch (WebSocketException) + { + // Normal close or network error + } +} + +app.Run(); diff --git a/LstWrapper/web.config b/LstWrapper/web.config index fd14c7d..ec6025a 100644 --- a/LstWrapper/web.config +++ b/LstWrapper/web.config @@ -1,36 +1,20 @@ + + + - - - - - - - - - - - - - - - - - - - - + + - + - @@ -38,9 +22,11 @@ + + - + - \ No newline at end of file + diff --git a/backend/cmd/services/logging/createLog.go b/backend/cmd/services/logging/createLog.go new file mode 100644 index 0000000..4b44852 --- /dev/null +++ b/backend/cmd/services/logging/createLog.go @@ -0,0 +1,111 @@ +package logging + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "lst.net/utils/db" +) + +type CustomLogger struct { + consoleLogger zerolog.Logger +} + +// New creates a configured CustomLogger. +func New() *CustomLogger { + // Colorized console output + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "2006-01-02 15:04:05", + } + + return &CustomLogger{ + consoleLogger: zerolog.New(consoleWriter). + With(). + Timestamp(). + Logger(), + } +} + +func PrettyFormat(level, message string, metadata map[string]interface{}) string { + timestamp := time.Now().Format("2006-01-02 15:04:05") + base := fmt.Sprintf("[%s] %s| Message: %s", strings.ToUpper(level), timestamp, message) + + if len(metadata) > 0 { + metaJSON, _ := json.Marshal(metadata) + return fmt.Sprintf("%s | Metadata: %s", base, string(metaJSON)) + } + return base +} + +func (l *CustomLogger) logToPostgres(level, message, service string, metadata map[string]interface{}) { + err := db.CreateLog(level, message, service, metadata) + if err != nil { + // Fallback to console if DB fails + log.Error().Err(err).Msg("Failed to write log to PostgreSQL") + } +} + +// --- Level-Specific Methods --- + +func (l *CustomLogger) Info(message, service string, fields map[string]interface{}) { + l.consoleLogger.Info().Fields(fields).Msg(message) + l.logToPostgres("info", message, service, fields) + + PostLog(PrettyFormat("info", message, fields)) // Broadcast pretty message +} + +func (l *CustomLogger) Warn(message, service string, fields map[string]interface{}) { + l.consoleLogger.Error().Fields(fields).Msg(message) + l.logToPostgres("warn", message, service, fields) + + PostLog(PrettyFormat("warn", message, fields)) // Broadcast pretty message + + // Custom logic for errors (e.g., alerting) + if len(fields) > 0 { + l.consoleLogger.Warn().Msg("Additional error context captured") + } +} + +func (l *CustomLogger) Error(message, service string, fields map[string]interface{}) { + l.consoleLogger.Error().Fields(fields).Msg(message) + l.logToPostgres("error", message, service, fields) + + PostLog(PrettyFormat("error", message, fields)) // Broadcast pretty message + + // Custom logic for errors (e.g., alerting) + if len(fields) > 0 { + l.consoleLogger.Warn().Msg("Additional error context captured") + } +} + +func (l *CustomLogger) Panic(message, service string, fields map[string]interface{}) { + // Log to console (colored, with fields) + l.consoleLogger.Error(). + Str("service", service). + Fields(fields). + Msg(message + " (PANIC)") // Explicitly mark as panic + + // Log to PostgreSQL (sync to ensure it's saved before crashing) + err := db.CreateLog("panic", message, service, fields) // isCritical=true + if err != nil { + l.consoleLogger.Error().Err(err).Msg("Failed to save panic log to PostgreSQL") + } + + // Additional context (optional) + if len(fields) > 0 { + l.consoleLogger.Warn().Msg("Additional panic context captured") + } + + panic(message) +} + +func (l *CustomLogger) Debug(message, service string, fields map[string]interface{}) { + l.consoleLogger.Debug().Fields(fields).Msg(message) + l.logToPostgres("debug", message, service, fields) +} diff --git a/backend/cmd/services/logging/logger.go b/backend/cmd/services/logging/logger.go new file mode 100644 index 0000000..05b731e --- /dev/null +++ b/backend/cmd/services/logging/logger.go @@ -0,0 +1,12 @@ +package logging + +import ( + "github.com/gin-gonic/gin" +) + +func RegisterLoggerRoutes(l *gin.Engine, baseUrl string) { + + configGroup := l.Group(baseUrl + "/api/logger") + configGroup.GET("/logs", GetLogs) + +} diff --git a/backend/cmd/services/logging/logs_get_route.go b/backend/cmd/services/logging/logs_get_route.go new file mode 100644 index 0000000..7476437 --- /dev/null +++ b/backend/cmd/services/logging/logs_get_route.go @@ -0,0 +1,132 @@ +package logging + +import ( + "fmt" + "log" + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var ( + logChannel = make(chan string, 1000) // Buffered channel for new logs + wsClients = make(map[*websocket.Conn]bool) + wsClientsMux sync.Mutex +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + fmt.Println("Origin:", r.Header.Get("Origin")) + return true + }, +} + +// PostLog sends a new log to all connected SSE clients +func PostLog(message string) { + // Send to SSE channel + select { + case logChannel <- message: + log.Printf("Published to SSE: %s", message) + default: + log.Printf("DROPPED SSE message (channel full): %s", message) + } + + wsClientsMux.Lock() + defer wsClientsMux.Unlock() + for client := range wsClients { + err := client.WriteMessage(websocket.TextMessage, []byte(message)) + if err != nil { + client.Close() + delete(wsClients, client) + } + } +} + +func GetLogs(c *gin.Context) { + // Check if it's a WebSocket request + if websocket.IsWebSocketUpgrade(c.Request) { + handleWebSocket(c) + return + } + + // Otherwise, handle as SSE + handleSSE(c) +} + +func handleSSE(c *gin.Context) { + log := New() + log.Info("SSE connection established", "logger", map[string]interface{}{ + "endpoint": "/api/logger/logs", + "client_ip": c.ClientIP(), + "user_agent": c.Request.UserAgent(), + }) + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + log.Info("SSE not supported", "logger", nil) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + notify := c.Writer.CloseNotify() + + for { + select { + case <-notify: + log.Info("SSE client disconnected", "logger", nil) + return + case message := <-logChannel: + fmt.Fprintf(c.Writer, "data: %s\n\n", message) + flusher.Flush() + } + } +} + +func handleWebSocket(c *gin.Context) { + log := New() + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Error("WebSocket upgrade failed", "logger", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + // Register client + wsClientsMux.Lock() + wsClients[conn] = true + wsClientsMux.Unlock() + + defer func() { + wsClientsMux.Lock() + delete(wsClients, conn) + wsClientsMux.Unlock() + conn.Close() + + log.Info("WebSocket client disconnected", "logger", map[string]interface{}{}) + }() + + // Keep connection alive (or optionally echo, or wait for pings) + for { + // Can just read to keep the connection alive + if _, _, err := conn.NextReader(); err != nil { + break + } + } +} + +// func sendRecentLogs(conn *websocket.Conn) { +// // Implement your logic to get recent logs from DB or buffer +// recentLogs := getLast20Logs() +// for _, log := range recentLogs { +// conn.WriteMessage(websocket.TextMessage, []byte(log)) +// } +// } diff --git a/backend/go.mod b/backend/go.mod index 5a968e5..6e3f2b5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,35 +3,57 @@ module lst.net go 1.24.3 require ( + github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.34.0 + github.com/swaggo/swag v1.16.5 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.30.0 ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.19.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/main.go b/backend/main.go index de5b980..225aac1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,24 +1,63 @@ +// @title My Awesome API +// @version 1.0 +// @description This is a sample server for a pet store. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:8080 +// @BasePath /api/v1 package main import ( + "errors" "fmt" - "log" + "net/http" "os" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/joho/godotenv" + "lst.net/cmd/services/logging" + _ "lst.net/docs" + "lst.net/utils/db" ) func main() { + log := logging.New() // Load .env only in dev (not Docker/production) if os.Getenv("RUNNING_IN_DOCKER") != "true" { err := godotenv.Load("../.env") if err != nil { - log.Println("Warning: .env file not found (ok in Docker/production)") + log.Info("Warning: .env file not found (ok in Docker/production)", "system", map[string]interface{}{}) } } + // Initialize DB + if err := db.InitDB(); err != nil { + log.Panic("Database intialize failed", "db", map[string]interface{}{ + "error": err.Error(), + "casue": errors.Unwrap(err), + "timeout": "30s", + "details": fmt.Sprintf("%+v", err), // Full stack trace if available + }) + } + defer func() { + if r := recover(); r != nil { + sqlDB, _ := db.DB.DB() + sqlDB.Close() + log.Error("Recovered from panic during DB shutdown", "db", map[string]interface{}{ + "panic": r, + }) + } + }() + // Set basePath dynamically basePath := "/" @@ -34,6 +73,16 @@ func main() { gin.SetMode(gin.ReleaseMode) } + // Enable CORS (adjust origins as needed) + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // Allow all origins (change in production) + AllowMethods: []string{"GET", "OPTIONS", "POST", "DELETE", "PATCH", "CONNECT"}, + AllowHeaders: []string{"Origin", "Cache-Control", "Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + AllowWebSockets: true, + })) + // // --- Add Redirects Here --- // // Redirect root ("/") to "/app" or "/lst/app" // r.GET("/", func(c *gin.Context) { @@ -52,34 +101,18 @@ func main() { r.StaticFS(basePath+"/app", http.Dir("frontend")) r.GET(basePath+"/api/ping", func(c *gin.Context) { + log.Info("Checking if the server is up", "system", map[string]interface{}{ + "endpoint": "/api/ping", + "client_ip": c.ClientIP(), + "user_agent": c.Request.UserAgent(), + }) c.JSON(200, gin.H{"message": "pong"}) }) + logging.RegisterLoggerRoutes(r, basePath) + r.Any(basePath+"/api", errorApiLoc) - // // Serve static assets for Vite app - // r.Static("/lst/app/assets", "./dist/app/assets") - - // // Catch-all for Vite app routes - // r.NoRoute(func(c *gin.Context) { - // path := c.Request.URL.Path - - // // Don't handle API, assets, or docs - // if strings.HasPrefix(path, "/lst/api") || - // strings.HasPrefix(path, "/lst/app/assets") || - // strings.HasPrefix(path, "/lst/docs") { - // c.JSON(404, gin.H{"error": "Not found"}) - // return - // } - - // // Serve index.html for all /lst/app routes - // if strings.HasPrefix(path, "/lst/app") { - // c.File("./dist/app/index.html") - // return - // } - - // c.JSON(404, gin.H{"error": "Not found"}) - // }) r.Run(":8080") } @@ -93,5 +126,11 @@ func main() { // c.JSON(http.StatusBadRequest, gin.H{"message": "welcome to lst system you might have just encountered an incorrect area of the app"}) // } func errorApiLoc(c *gin.Context) { + log := logging.New() + log.Info("Api endpoint hit that dose not exist", "system", map[string]interface{}{ + "endpoint": "/api", + "client_ip": c.ClientIP(), + "user_agent": c.Request.UserAgent(), + }) c.JSON(http.StatusBadRequest, gin.H{"message": "looks like you have encountered an api route that dose not exist"}) } diff --git a/backend/utils/db/db.go b/backend/utils/db/db.go new file mode 100644 index 0000000..3f5cdd7 --- /dev/null +++ b/backend/utils/db/db.go @@ -0,0 +1,32 @@ +package db + +import ( + "fmt" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB() error { + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s", + os.Getenv("DB_HOST"), + os.Getenv("DB_PORT"), + os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), + os.Getenv("DB_NAME")) + + var err error + + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return fmt.Errorf("failed to connect to database: %v", err) + } + + // Auto-migrate all models + DB.AutoMigrate(&Log{}) // Add other models here + + return nil +} diff --git a/backend/utils/db/logs.go b/backend/utils/db/logs.go new file mode 100644 index 0000000..fcabd09 --- /dev/null +++ b/backend/utils/db/logs.go @@ -0,0 +1,41 @@ +package db + +import "time" + +type Log struct { + ID uint `gorm:"primaryKey"` + Level string `gorm:"size:10;not null"` // "info", "error", etc. + Message string `gorm:"not null"` + Service string `gorm:"size:50"` // Optional: service name + Metadata JSONB `gorm:"type:jsonb"` // Structured fields (e.g., {"user_id": 123}) + CreatedAt time.Time `gorm:"index"` // Auto-set by GORM + Checked bool `gorm:"type:boolean;default:false"` +} + +// JSONB is a helper type for PostgreSQL JSONB fields. +type JSONB map[string]interface{} + +// --- CRUD Operations --- + +// CreateLog inserts a new log entry. +func CreateLog(level, message, service string, metadata JSONB) error { + log := Log{ + Level: level, + Message: message, + Service: service, + Metadata: metadata, + } + return DB.Create(&log).Error +} + +// GetLogsByLevel fetches logs filtered by severity. +func GetLogsByLevel(level string, limit int) ([]Log, error) { + var logs []Log + err := DB.Where("level = ?", level).Limit(limit).Find(&logs).Error + return logs, err +} + +// DeleteOldLogs removes logs older than `days`. +func DeleteOldLogs(days int) error { + return DB.Where("created_at < ?", time.Now().AddDate(0, 0, -days)).Delete(&Log{}).Error +}