diff --git a/go.mod b/go.mod index 359c7b5..1f59138 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( github.com/go-redsync/redsync/v4 v4.8.1 github.com/gofiber/fiber/v2 v2.48.0 - github.com/mcstatus-io/mcutil v1.4.0 + github.com/mcstatus-io/mcutil/v2 v2.0.1 github.com/redis/go-redis/v9 v9.1.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 80a7824..0cab53b 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mcstatus-io/mcutil v1.4.0 h1:UpEaOm/PGIDcPFQqAXY14uhsgaclM3ZsEf6ojuAXZGE= -github.com/mcstatus-io/mcutil v1.4.0/go.mod h1:VPWRCaYXfQheaTt4XJGYOGFzrp2E+B8g393SQiYO5mA= +github.com/mcstatus-io/mcutil/v2 v2.0.1 h1:tIyOBYoa+DygzCkGIhU7/STA2gOIcWqS45/p4U58xmE= +github.com/mcstatus-io/mcutil/v2 v2.0.1/go.mod h1:GmQmohJjxYHkCI9qyK7rvfrZDqwwyKomnMbvXAz/US4= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/src/routes.go b/src/routes.go index 2fec7a4..dfffbf5 100644 --- a/src/routes.go +++ b/src/routes.go @@ -1,18 +1,18 @@ package main import ( + "context" "fmt" "main/src/assets" "net/http" "strconv" - "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/mcstatus-io/mcutil" - "github.com/mcstatus-io/mcutil/options" + "github.com/mcstatus-io/mcutil/v2" + "github.com/mcstatus-io/mcutil/v2/options" ) func init() { @@ -54,6 +54,12 @@ func FaviconHandler(ctx *fiber.Ctx) error { // JavaStatusHandler returns the status of the Java edition Minecraft server specified in the address parameter. func JavaStatusHandler(ctx *fiber.Ctx) error { + opts, err := GetStatusOptions(ctx) + + if err != nil { + return err + } + host, port, err := ParseAddress(ctx.Params("address"), 25565) if err != nil { @@ -64,7 +70,7 @@ func JavaStatusHandler(ctx *fiber.Ctx) error { return err } - response, expiresAt, err := GetJavaStatus(host, port, ctx.QueryBool("query", true)) + response, expiresAt, err := GetJavaStatus(host, port, opts) if err != nil { return err @@ -81,6 +87,12 @@ func JavaStatusHandler(ctx *fiber.Ctx) error { // BedrockStatusHandler returns the status of the Bedrock edition Minecraft server specified in the address parameter. func BedrockStatusHandler(ctx *fiber.Ctx) error { + opts, err := GetStatusOptions(ctx) + + if err != nil { + return err + } + host, port, err := ParseAddress(ctx.Params("address"), 19132) if err != nil { @@ -91,7 +103,7 @@ func BedrockStatusHandler(ctx *fiber.Ctx) error { return err } - response, expiresAt, err := GetBedrockStatus(host, port) + response, expiresAt, err := GetBedrockStatus(host, port, opts) if err != nil { return err @@ -108,13 +120,19 @@ func BedrockStatusHandler(ctx *fiber.Ctx) error { // IconHandler returns the server icon for the specified Java edition Minecraft server. func IconHandler(ctx *fiber.Ctx) error { + opts, err := GetStatusOptions(ctx) + + if err != nil { + return err + } + host, port, err := ParseAddress(ctx.Params("address"), 25565) if err != nil { return ctx.Status(http.StatusBadRequest).SendString("Invalid address value") } - icon, expiresAt, err := GetServerIcon(host, port) + icon, expiresAt, err := GetServerIcon(host, port, opts) if err != nil { return err @@ -131,7 +149,7 @@ func IconHandler(ctx *fiber.Ctx) error { // SendVoteHandler allows sending of Votifier votes to the specified server. func SendVoteHandler(ctx *fiber.Ctx) error { - opts, err := ParseVoteOptions(ctx) + opts, err := GetVoteOptions(ctx) if err != nil { return ctx.Status(http.StatusBadRequest).SendString(err.Error()) @@ -140,26 +158,34 @@ func SendVoteHandler(ctx *fiber.Ctx) error { switch opts.Version { case 1: { - if err = mcutil.SendLegacyVote(opts.Host, opts.Port, options.LegacyVote{ + c, cancel := context.WithTimeout(context.Background(), opts.Timeout) + + defer cancel() + + if err = mcutil.SendLegacyVote(c, opts.Host, opts.Port, options.LegacyVote{ PublicKey: opts.PublicKey, ServiceName: opts.ServiceName, Username: opts.Username, IPAddress: opts.IPAddress, Timestamp: opts.Timestamp, - Timeout: time.Second * 5, + Timeout: opts.Timeout, }); err != nil { return ctx.Status(http.StatusBadRequest).SendString(err.Error()) } } case 2: { - if err = mcutil.SendVote(opts.Host, opts.Port, options.Vote{ + c, cancel := context.WithTimeout(context.Background(), opts.Timeout) + + defer cancel() + + if err = mcutil.SendVote(c, opts.Host, opts.Port, options.Vote{ ServiceName: opts.ServiceName, Username: opts.Username, Token: opts.Token, UUID: opts.UUID, Timestamp: opts.Timestamp, - Timeout: time.Second * 5, + Timeout: opts.Timeout, }); err != nil { return ctx.Status(http.StatusBadRequest).SendString(err.Error()) } diff --git a/src/status.go b/src/status.go index 4743738..43b7ae7 100644 --- a/src/status.go +++ b/src/status.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -10,10 +11,10 @@ import ( "sync" "time" - "github.com/mcstatus-io/mcutil" - "github.com/mcstatus-io/mcutil/description" - "github.com/mcstatus-io/mcutil/options" - "github.com/mcstatus-io/mcutil/response" + "github.com/mcstatus-io/mcutil/v2" + "github.com/mcstatus-io/mcutil/v2/formatting" + "github.com/mcstatus-io/mcutil/v2/options" + "github.com/mcstatus-io/mcutil/v2/response" ) // BaseStatus is the base response properties for returning any status response from the API. @@ -114,8 +115,8 @@ type Plugin struct { } // GetJavaStatus returns the status response of a Java Edition server, either using cache or fetching a fresh status. -func GetJavaStatus(host string, port uint16, query bool) (*JavaStatusResponse, time.Duration, error) { - cacheKey := GetCacheKey(host, port, query) +func GetJavaStatus(host string, port uint16, opts *StatusOptions) (*JavaStatusResponse, time.Duration, error) { + cacheKey := GetCacheKey(host, port, opts) // Wait for any other processes to finish fetching the status of this server if conf.Cache.EnableLocks { @@ -144,7 +145,7 @@ func GetJavaStatus(host string, port uint16, query bool) (*JavaStatusResponse, t // Fetch a fresh status from the server itself { - response := FetchJavaStatus(host, port, query) + response := FetchJavaStatus(host, port, opts) data, err := json.Marshal(response) @@ -161,8 +162,8 @@ func GetJavaStatus(host string, port uint16, query bool) (*JavaStatusResponse, t } // GetBedrockStatus returns the status response of a Bedrock Edition server, either using cache or fetching a fresh status. -func GetBedrockStatus(host string, port uint16) (*BedrockStatusResponse, time.Duration, error) { - cacheKey := GetCacheKey(host, port, false) +func GetBedrockStatus(host string, port uint16, opts *StatusOptions) (*BedrockStatusResponse, time.Duration, error) { + cacheKey := GetCacheKey(host, port, nil) // Wait for any other processes to finish fetching the status of this server if conf.Cache.EnableLocks { @@ -191,7 +192,7 @@ func GetBedrockStatus(host string, port uint16) (*BedrockStatusResponse, time.Du // Fetch a fresh status from the server itself { - response := FetchBedrockStatus(host, port) + response := FetchBedrockStatus(host, port, opts) data, err := json.Marshal(response) @@ -208,8 +209,8 @@ func GetBedrockStatus(host string, port uint16) (*BedrockStatusResponse, time.Du } // GetServerIcon returns the icon image of a Java Edition server, either using cache or fetching a fresh image. -func GetServerIcon(host string, port uint16) ([]byte, time.Duration, error) { - cacheKey := GetCacheKey(host, port, false) +func GetServerIcon(host string, port uint16, opts *StatusOptions) ([]byte, time.Duration, error) { + cacheKey := GetCacheKey(host, port, nil) // Fetch the cached icon if it exists { @@ -230,7 +231,11 @@ func GetServerIcon(host string, port uint16) ([]byte, time.Duration, error) { // Fetch the icon from the server itself { - status, err := mcutil.Status(host, port) + ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) + + defer cancel() + + status, err := mcutil.Status(ctx, host, port) if err == nil && status.Favicon != nil && strings.HasPrefix(*status.Favicon, "data:image/png;base64,") { data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(*status.Favicon, "data:image/png;base64,")) @@ -254,38 +259,74 @@ func GetServerIcon(host string, port uint16) ([]byte, time.Duration, error) { } // FetchJavaStatus fetches fresh information about a Java Edition Minecraft server. -func FetchJavaStatus(host string, port uint16, enableQuery bool) JavaStatusResponse { +func FetchJavaStatus(host string, port uint16, opts *StatusOptions) JavaStatusResponse { var wg sync.WaitGroup - wg.Add(1) + wg.Add(2) - if enableQuery { + if opts.Query { wg.Add(1) } + statusContext, statusCancel := context.WithTimeout(context.Background(), opts.Timeout) + legacyContext, legacyCancel := context.WithTimeout(context.Background(), opts.Timeout) + queryContext, queryCancel := context.WithTimeout(context.Background(), opts.Timeout) + + defer statusCancel() + defer legacyCancel() + defer queryCancel() + var ( - statusResult interface{} = nil - queryResult *response.FullQuery = nil + statusResult *response.JavaStatus = nil + legacyStatusResult *response.JavaStatusLegacy = nil + queryResult *response.FullQuery = nil ) // Status { go func() { - if result, _ := mcutil.Status(host, port); result != nil { - statusResult = result - } else if result, _ := mcutil.StatusLegacy(host, port); result != nil { + if result, _ := mcutil.Status(statusContext, host, port, options.JavaStatus{ + Timeout: opts.Timeout - time.Millisecond*100, + }); result != nil { statusResult = result } wg.Done() + + legacyCancel() + + time.Sleep(time.Millisecond * 250) + + if queryResult == nil { + queryCancel() + } + }() + } + + // Legacy Status + { + go func() { + if result, _ := mcutil.StatusLegacy(legacyContext, host, port, options.JavaStatusLegacy{ + Timeout: opts.Timeout - time.Millisecond*100, + }); result != nil { + legacyStatusResult = result + } + + wg.Done() + + time.Sleep(time.Millisecond * 250) + + if queryResult == nil { + queryCancel() + } }() } // Query - if enableQuery { + if opts.Query { go func() { - queryResult, _ = mcutil.FullQuery(host, port, options.Query{ - Timeout: time.Second, + queryResult, _ = mcutil.FullQuery(queryContext, host, port, options.Query{ + Timeout: opts.Timeout - time.Millisecond*100, }) wg.Done() @@ -294,18 +335,22 @@ func FetchJavaStatus(host string, port uint16, enableQuery bool) JavaStatusRespo wg.Wait() - return BuildJavaResponse(host, port, statusResult, queryResult) + return BuildJavaResponse(host, port, statusResult, legacyStatusResult, queryResult) } // FetchBedrockStatus fetches a fresh status of a Bedrock Edition server. -func FetchBedrockStatus(host string, port uint16) BedrockStatusResponse { - status, _ := mcutil.StatusBedrock(host, port) +func FetchBedrockStatus(host string, port uint16, opts *StatusOptions) BedrockStatusResponse { + ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) + + defer cancel() + + status, _ := mcutil.StatusBedrock(ctx, host, port) return BuildBedrockResponse(host, port, status) } // BuildJavaResponse builds the response data from the status and query information. -func BuildJavaResponse(host string, port uint16, status interface{}, query *response.FullQuery) (result JavaStatusResponse) { +func BuildJavaResponse(host string, port uint16, status *response.JavaStatus, legacyStatus *response.JavaStatusLegacy, query *response.FullQuery) (result JavaStatusResponse) { result = JavaStatusResponse{ BaseStatus: BaseStatus{ Online: false, @@ -319,94 +364,77 @@ func BuildJavaResponse(host string, port uint16, status interface{}, query *resp } // Status - switch s := status.(type) { - case *response.JavaStatus: - { - if s == nil { - break - } + if status != nil { + result.Online = true - result.Online = true - - result.JavaStatus = &JavaStatus{ - Version: &JavaVersion{ - NameRaw: s.Version.NameRaw, - NameClean: s.Version.NameClean, - NameHTML: s.Version.NameHTML, - Protocol: s.Version.Protocol, - }, - Players: JavaPlayers{ - Online: s.Players.Online, - Max: s.Players.Max, - List: make([]Player, 0), - }, - MOTD: MOTD{ - Raw: s.MOTD.Raw, - Clean: s.MOTD.Clean, - HTML: s.MOTD.HTML, - }, - Icon: s.Favicon, - Mods: make([]Mod, 0), - Plugins: make([]Plugin, 0), - } - - if s.Players.Sample != nil { - for _, player := range s.Players.Sample { - result.Players.List = append(result.Players.List, Player{ - UUID: player.ID, - NameRaw: player.NameRaw, - NameClean: player.NameClean, - NameHTML: player.NameHTML, - }) - } - } - - if s.ModInfo != nil { - for _, mod := range s.ModInfo.Mods { - result.Mods = append(result.Mods, Mod{ - Name: mod.ID, - Version: mod.Version, - }) - } - } - - break + result.JavaStatus = &JavaStatus{ + Version: &JavaVersion{ + NameRaw: status.Version.NameRaw, + NameClean: status.Version.NameClean, + NameHTML: status.Version.NameHTML, + Protocol: status.Version.Protocol, + }, + Players: JavaPlayers{ + Online: status.Players.Online, + Max: status.Players.Max, + List: make([]Player, 0), + }, + MOTD: MOTD{ + Raw: status.MOTD.Raw, + Clean: status.MOTD.Clean, + HTML: status.MOTD.HTML, + }, + Icon: status.Favicon, + Mods: make([]Mod, 0), + Plugins: make([]Plugin, 0), } - case *response.JavaStatusLegacy: - { - if s == nil { - break + + if status.Players.Sample != nil { + for _, player := range status.Players.Sample { + result.Players.List = append(result.Players.List, Player{ + UUID: player.ID, + NameRaw: player.NameRaw, + NameClean: player.NameClean, + NameHTML: player.NameHTML, + }) } + } - result.Online = true - - result.JavaStatus = &JavaStatus{ - Version: nil, - Players: JavaPlayers{ - Online: &s.Players.Online, - Max: &s.Players.Max, - List: make([]Player, 0), - }, - MOTD: MOTD{ - Raw: s.MOTD.Raw, - Clean: s.MOTD.Clean, - HTML: s.MOTD.HTML, - }, - Icon: nil, - Mods: make([]Mod, 0), - Plugins: make([]Plugin, 0), + if status.ModInfo != nil { + for _, mod := range status.ModInfo.Mods { + result.Mods = append(result.Mods, Mod{ + Name: mod.ID, + Version: mod.Version, + }) } + } + } else if legacyStatus != nil { + result.Online = true - if s.Version != nil { - result.Version = &JavaVersion{ - NameRaw: s.Version.NameRaw, - NameClean: s.Version.NameClean, - NameHTML: s.Version.NameHTML, - Protocol: s.Version.Protocol, - } + result.JavaStatus = &JavaStatus{ + Version: nil, + Players: JavaPlayers{ + Online: &legacyStatus.Players.Online, + Max: &legacyStatus.Players.Max, + List: make([]Player, 0), + }, + MOTD: MOTD{ + Raw: legacyStatus.MOTD.Raw, + Clean: legacyStatus.MOTD.Clean, + HTML: legacyStatus.MOTD.HTML, + }, + Icon: nil, + Mods: make([]Mod, 0), + Plugins: make([]Plugin, 0), + } + + if legacyStatus.Version != nil { + result.Version = &JavaVersion{ + NameRaw: legacyStatus.Version.NameRaw, + NameClean: legacyStatus.Version.NameClean, + NameHTML: legacyStatus.Version.NameHTML, + Protocol: legacyStatus.Version.Protocol, } - - break } } @@ -424,7 +452,7 @@ func BuildJavaResponse(host string, port uint16, status interface{}, query *resp } if motd, ok := query.Data["hostname"]; ok { - if parsedMOTD, err := description.ParseFormatting(motd); err == nil { + if parsedMOTD, err := formatting.Parse(motd); err == nil { result.MOTD = MOTD{ Raw: parsedMOTD.Raw, Clean: parsedMOTD.Clean, @@ -450,7 +478,7 @@ func BuildJavaResponse(host string, port uint16, status interface{}, query *resp } if version, ok := query.Data["version"]; ok { - parsedValue, err := description.ParseFormatting(version) + parsedValue, err := formatting.Parse(version) if err == nil { result.Version = &JavaVersion{ @@ -490,7 +518,7 @@ func BuildJavaResponse(host string, port uint16, status interface{}, query *resp continue } - parsedName, err := description.ParseFormatting(username) + parsedName, err := formatting.Parse(username) if err == nil { result.Players.List = append(result.Players.List, Player{ @@ -507,7 +535,7 @@ func BuildJavaResponse(host string, port uint16, status interface{}, query *resp } // BuildBedrockResponse builds the response data from the status information. -func BuildBedrockResponse(host string, port uint16, status interface{}) (result BedrockStatusResponse) { +func BuildBedrockResponse(host string, port uint16, status *response.BedrockStatus) (result BedrockStatusResponse) { result = BedrockStatusResponse{ BaseStatus: BaseStatus{ Online: false, @@ -520,77 +548,68 @@ func BuildBedrockResponse(host string, port uint16, status interface{}) (result BedrockStatus: nil, } - switch s := status.(type) { - case *response.BedrockStatus: - { - if s == nil { - break - } + if status != nil { + result.Online = true - result.Online = true + result.BedrockStatus = &BedrockStatus{ + Version: nil, + Players: nil, + MOTD: nil, + Gamemode: status.Gamemode, + ServerID: status.ServerID, + Edition: status.Edition, + } - result.BedrockStatus = &BedrockStatus{ - Version: nil, - Players: nil, - MOTD: nil, - Gamemode: s.Gamemode, - ServerID: s.ServerID, - Edition: s.Edition, - } - - if s.Version != nil { - if result.Version == nil { - result.Version = &BedrockVersion{ - Name: nil, - Protocol: nil, - } - } - - result.Version.Name = s.Version - } - - if s.ProtocolVersion != nil { - if result.Version == nil { - result.Version = &BedrockVersion{ - Name: nil, - Protocol: nil, - } - } - - result.Version.Protocol = s.ProtocolVersion - } - - if s.OnlinePlayers != nil { - if result.Players == nil { - result.Players = &BedrockPlayers{ - Online: nil, - Max: nil, - } - } - - result.Players.Online = s.OnlinePlayers - } - - if s.MaxPlayers != nil { - if result.Players == nil { - result.Players = &BedrockPlayers{ - Online: nil, - Max: nil, - } - } - - result.Players.Max = s.MaxPlayers - } - - if s.MOTD != nil { - result.MOTD = &MOTD{ - Raw: s.MOTD.Raw, - Clean: s.MOTD.Clean, - HTML: s.MOTD.HTML, + if status.Version != nil { + if result.Version == nil { + result.Version = &BedrockVersion{ + Name: nil, + Protocol: nil, } } - break + result.Version.Name = status.Version + } + + if status.ProtocolVersion != nil { + if result.Version == nil { + result.Version = &BedrockVersion{ + Name: nil, + Protocol: nil, + } + } + + result.Version.Protocol = status.ProtocolVersion + } + + if status.OnlinePlayers != nil { + if result.Players == nil { + result.Players = &BedrockPlayers{ + Online: nil, + Max: nil, + } + } + + result.Players.Online = status.OnlinePlayers + } + + if status.MaxPlayers != nil { + if result.Players == nil { + result.Players = &BedrockPlayers{ + Online: nil, + Max: nil, + } + } + + result.Players.Max = status.MaxPlayers + } + + if status.MOTD != nil { + result.MOTD = &MOTD{ + Raw: status.MOTD.Raw, + Clean: status.MOTD.Clean, + HTML: status.MOTD.HTML, + } } } diff --git a/src/util.go b/src/util.go index 299f91d..e26f76d 100644 --- a/src/util.go +++ b/src/util.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "math" "net/http" "net/url" "os" @@ -38,6 +39,13 @@ type VoteOptions struct { Token string PublicKey string Timestamp time.Time + Timeout time.Duration +} + +// StatusOptions is the options provided as query parameters to the status route. +type StatusOptions struct { + Query bool + Timeout time.Duration } // MutexArray is a thread-safe array for storing and retrieving values. @@ -140,8 +148,8 @@ func ParseAddress(address string, defaultPort uint16) (string, uint16, error) { return host, uint16(port), nil } -// ParseVoteOptions parses the vote options from the provided query parameters. -func ParseVoteOptions(ctx *fiber.Ctx) (*VoteOptions, error) { +// GetVoteOptions parses the vote options from the provided query parameters. +func GetVoteOptions(ctx *fiber.Ctx) (*VoteOptions, error) { result := VoteOptions{} // Version @@ -232,9 +240,30 @@ func ParseVoteOptions(ctx *fiber.Ctx) (*VoteOptions, error) { } } + // Timeout + { + result.Timeout = time.Duration(math.Max(float64(time.Second)*ctx.QueryFloat("timeout", 5.0), float64(time.Millisecond*250))) + } + return &result, nil } +func GetStatusOptions(ctx *fiber.Ctx) (*StatusOptions, error) { + result := &StatusOptions{} + + // Query + { + result.Query = ctx.QueryBool("query", true) + } + + // Timeout + { + result.Timeout = time.Duration(math.Max(float64(time.Second)*ctx.QueryFloat("timeout", 5.0), float64(time.Millisecond*500))) + } + + return result, nil +} + // GetInstanceID returns the INSTANCE_ID environment variable parsed as an unsigned 16-bit integer. func GetInstanceID() (uint16, error) { if instanceID := os.Getenv("INSTANCE_ID"); len(instanceID) > 0 { @@ -251,11 +280,14 @@ func GetInstanceID() (uint16, error) { } // GetCacheKey generates a unique key used for caching status results in Redis. -func GetCacheKey(host string, port uint16, query bool) string { +func GetCacheKey(host string, port uint16, opts *StatusOptions) string { values := &url.Values{} values.Set("host", host) values.Set("port", strconv.FormatUint(uint64(port), 10)) - values.Set("query", strconv.FormatBool(query)) + + if opts != nil { + values.Set("query", strconv.FormatBool(opts.Query)) + } return SHA256(values.Encode()) }