diff --git a/.gitignore b/.gitignore index 7986524..ccb190a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ .DS_Store *.db .envrc -*.sqlite \ No newline at end of file +*.sqlite +**/jwtprivatekey* +release +build.sh +changelog \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2a73ff4..b87d02a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,43 @@ -FROM golang:1.22.2-bullseye as build +# Fetch UPX +FROM alpine:latest AS upx +WORKDIR / +RUN apk update && apk add ca-certificates && \ + arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && echo "ARCHITECTURE=${arch}" && \ + wget "https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-${arch}_linux.tar.xz" && \ + tar -xf upx-4.2.4-${arch}_linux.tar.xz && \ + cd upx-4.2.4-${arch}_linux && \ + mv upx /bin/upx -ENV CGO_ENABLED=1 +# Build openfsd +FROM golang:1.23.2-bookworm AS build -WORKDIR /build +# Precompile the entire go standard library into the first Docker cache layer +RUN CGO_ENABLED=0 GOOS=linux go install -v -a std + +WORKDIR /go/src/openfsd + +# Download and precompile all third party libraries +COPY go.mod . +COPY go.sum . +RUN go mod download -x +RUN go list -m all | tail -n +2 | cut -f 1 -d " " | awk 'NF{print $0 "/..."}' | CGO_ENABLED=0 GOOS=linux xargs -n1 go build -v; echo done + +# Add the sources COPY . . -RUN go mod download -RUN go mod verify +# Compile +RUN CGO_ENABLED=0 GOOS=linux go build -v -o openfsd -ldflags "-s -w" main.go -RUN go build -ldflags='-extldflags "-static"' -o main . +# Move UPX into /bin +COPY --from=upx /bin/upx /bin/upx -FROM gcr.io/distroless/static-debian11 +# Compress with upx +RUN /bin/upx -v -9 openfsd -WORKDIR /openfsd +FROM gcr.io/distroless/static-debian12 -COPY --from=build /build/main . +WORKDIR /app +COPY --from=build --chown=nonroot:nonroot /go/src/openfsd /app +USER nonroot:nonroot -CMD ["./main"] \ No newline at end of file +ENTRYPOINT ["/app/openfsd"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c68924 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Reese Norris + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0c70627..762ca47 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,152 @@ # openfsd -**openfsd** is an experimental FSD server, implementing the `Vatsim2022` protocol revision ([Velocity](https://forums.vatsim.net/topic/32619-vatsim-announces-velocity-release-date-and-rollout-plan/)) for pilot clients. +[![license](https://img.shields.io/github/license/renorris/openfsd)](https://github.com/renorris/openfsd/blob/main/LICENSE) + [![docker](https://img.shields.io/docker/image-size/renorris/openfsd/latest)](https://hub.docker.com/repository/docker/renorris/openfsd) + +**openfsd** is a multiplayer flight simulation server implementing the protocol colloquially known as "FSD" (Flight Sim Daemon). +It is specifically modelled to reflect the VATSIM [Velocity](https://forums.vatsim.net/topic/32619-vatsim-announces-velocity-release-date-and-rollout-plan/) protocol revision for pilot clients. ## About -FSD (Flight Sim Daemon) is the software/protocol responsible for connecting home flight simulator clients to a single, shared multiplayer world on hobbyist networks such as [VATSIM](https://vatsim.net/docs/about/about-vatsim). -FSD was originally written in the late 90's by [Marty Bochane](https://github.com/kuroneko/fsd) for [SATCO](https://web.archive.org/web/20000619145015/http://www.satco.org/), later to be forked and taken closed-source by VATSIM in 2001. As of April 2024, FSD is still used to facilitate over 140,000 active members connecting their flight simulators to the [network](https://map.vatsim.net). +FSD is the software/protocol responsible for connecting home flight simulator clients to a single, shared multiplayer world on hobbyist networks such as [VATSIM](https://vatsim.net/docs/about/about-vatsim) and [IVAO](https://www.ivao.aero/). +FSD was originally written in the late 90's by [Marty Bochane](https://github.com/kuroneko/fsd) for [SATCO](https://web.archive.org/web/20000619145015/http://www.satco.org/), later to be forked and taken closed-source by VATSIM in 2001. +As of October 2024, FSD is still used to facilitate over 140,000 active members connecting their flight simulators to the [network](https://vatsim-radar.com/). + +## Docker + +[Prebuilt images](https://hub.docker.com/r/renorris/openfsd) are available for x86_64 and arm64. + +Example: -## Building ``` -go mod download -go build -o fsd . +docker run -e IN_MEMORY_DB=true -e PLAINTEXT_PASSWORDS=true \ +-p 6809:6809 -p 8080:8080 renorris/openfsd:latest ``` -A default admin user will be printed to stdout on first startup. A simple web interface can be accessed using these credentials to add more users at `/dashboard` +Also see the example [docker-compose.yml](https://github.com/renorris/openfsd/blob/main/docker-compose.yml). -The server is configured via environment variables: +## Manual Build +``` +git clone https://github.com/renorris/openfsd && cd openfsd/ +go build -o openfsd . +``` -| Variable Name | Default Value | Description | -|-----------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------| -| `FSD_ADDR` | 0.0.0.0:6809 | FSD listen address | -| `HTTP_ADDR` | 0.0.0.0:9086 | HTTP listen address | -| `HTTPS_ENABLED` | false | Enable HTTPS | -| `TLS_CERT_FILE` | | TLS certificate file path | -| `TLS_KEY_FILE` | | TLS key file path | -| `DATABASE_FILE` | ./fsd.db | SQLite database file path | -| `MOTD` | openfsd | Message to send on FSD client login (line feeds supported) | -| `PLAINTEXT_PASSWORDS` | false | Setting this to true treats the "token" field in the #AP packet to be a plaintext password, rather than a VATSIM-esque JWT token. | -## Overview +## Setup + +Persistent storage utilizes MySQL. You will need a MySQL server to point openfsd at. + +Use the following environment variables to configure the server: + +| Variable Name | Default Value | Description | +|-----------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `FSD_ADDR` | 0.0.0.0:6809 | FSD listen address | +| `HTTP_ADDR` | 0.0.0.0:8080 | HTTP listen address | +| `TLS_CERT_FILE` | | TLS certificate file path (setting this enables HTTPS. otherwise, plaintext HTTP will be used.) | +| `TLS_KEY_FILE` | | TLS key file path | +| `IN_MEMORY_DB` | false | Enables an ephemeral in-memory database in place of a real MySQL server.
This should only be used for testing. | +| `MYSQL_USER` | | MySQL username | +| `MYSQL_PASS` | | MySQL password | +| `MYSQL_NET` | | MySQL network protocol e.g. `tcp` | +| `MYSQL_ADDR` | | MySQL network address e.g. `127.0.0.1:3306` | +| `MYSQL_DBNAME` | | MySQL database name | +| `MOTD` | openfsd | "Message of the Day." This text is sent as a chat message to each client upon successful login to FSD. | +| `PLAINTEXT_PASSWORDS` | false | Setting this flag disables JSON web token authentication over FSD and instead treats the token field in the #AP packet as a plaintext password.
See below for further configuration. | + +For 99.9% of use cases, it is also recommended to set: +``` +GOMAXPROCS=1 +``` +This environment variable limits the number of operating system threads that can execute user-level Go code simultaneously. +Scaling to multiple threads will generally only make the process slower. + +openfsd supports "fsd-jwt" authentication over HTTP, assuming `PLAINTEXT_PASSWORDS=false` is set. +VATSIM uses this standard; clients first obtain a login token by POSTing to `/api/fsd-jwt` with: + +```json +{ + "cid": "9999999", + "password": "supersecretpassword" +} +``` + +A token is returned. That token is placed in the +token/password field of the `#AP` FSD login packet. +To use a vanilla VATSIM client with openfsd, +(except Swift, see below) it will need to be modified to point to openfsd's "fsd-jwt" endpoint: +``` +/api/v1/fsd-jwt +``` + +### Credentials + +A default administrator user will be printed to stdout on first startup: +``` + DEFAULT ADMINISTRATOR USER: + CID: 100000 + PRIMARY PASSWORD: + FSD PASSWORD: +``` +Two unique passwords are stored for each user since FSD runs over an insecure channel. +Use the primary password over a secure channel if possible (web interface over HTTPS) to perform sensitive account-related management. +Use the FSD password in your pilot client to connect to FSD. + +### Web Interface + +One can access their account via the web interface served by the HTTP endpoint. +Administrators and supervisors can create/mutate user records via the administrator dashboard. + +### Available HTTP calls + +- `/api/v1/users` (See [documentation](https://github.com/renorris/openfsd/tree/main/web)) +- `/api/v1/data/openfsd-data.json` VATSIM-esque [data feed](https://github.com/renorris/openfsd/blob/main/web/DATAFEED.md) +- `/login ... etc` front-end interface + +## Connecting Various clients such as [vPilot](https://vpilot.rosscarlson.dev/), [xPilot](https://docs.xpilot-project.org/) and [swift](https://swift-project.org/) are used to connect to VATSIM FSD servers. -This project does not currently support air traffic control clients. +Although it is possible to use vPilot with openfsd, the binary would need to be modified directly. +To use xPilot, one would need to manually recompile with the correct JWT token endpoint and FSD server addresses. -At its core, FSD is a message forwarder. Other than a few direct client/server transactions, the main purpose of the FSD server is to facilitate the passing of messages between flight simulator clients. Albeit, none of this is P2P—all messages are forwarded via a centralized server. +The Swift pilot client works out of the box if openfsd is configured with the `PLAINTEXT_PASSWORDS=true` environment variable option. -### Protocol +**Swift Instructions:** -The protocol is entirely plaintext, and can be easily sniffed using packet capture tools such as Wireshark. FSD conventionally listens on TCP port 6809. You can use telnet to interact with an FSD server, try it out: +1. In the Settings > Servers menu: Make a new server entry for openfsd with the correct address and port. +2. Select **FSD (Private)** for the "Eco." field +3. Select **FSD [VATSIM]** for the "Type" field. +4. In the FSD tab, enable the following flags (send and receive to TRUE): + - "Parts" + - "Gnd. flag" + - "Fast pos" + - "Send visual pos." + +## Protocol Details + +At its core, FSD is a message forwarder. +Other than a few direct client/server transactions, the main purpose of the FSD server is to facilitate the passing of messages between flight simulator clients. +Albeit, none of this is P2P—all messages are forwarded via a centralized server. +The protocol is entirely plaintext, and can be easily sniffed using packet capture tools such as Wireshark. +It conventionally listens on TCP port 6809. +One can use telnet to interact with an FSD server, try it out: ``` -telnet fsd.connect.vatsim.net 6809 +telnet 6809 ``` The few mainstream implementations of FSD have their own nuances within their respective protocols. This project attempts to replicate VATSIM-specific behavior (which differs from other implementations such as [IVAO](https://www.ivao.aero/)). -Throughout this project, I often refer to FSD messages as "packets" or "lines." The term "packet" is referring to the application-layer implementation of FSD, and has nothing to do with any transport or IP layers. +Throughout this project, FSD messages are referred to as "packets" or "lines." The term "packet" is referring to the application-layer implementation of FSD and has nothing to do with any transport or IP layers. FSD packets are plaintext MS-DOS-style lines that end with (CR/LF) characters. -Each line starts with a packet identifier, which is either 1 or 3 characters in length. Each field within the packet is delimited by a colon `:` character. All numerical values are represented as base-10 (or rarely base-16) encoded ASCII strings. There are no "binary" encodings in the protocol. +Each line starts with a packet identifier, which is either 1 or 3 characters in length. +Each field within the packet is delimited by a colon `:` character. +All numerical values are represented as base-10 (or rarely base-16) encoded ASCII strings. +Nothing is encoded as raw binary. Clients are addressed by their plaintext aviation callsigns, e.g. N7938C. FSD packets generally have "From" and "To" fields, where the "From" field can be thought of as a source address (or source callsign, in this case) and the "To" field being a recipient address/callsign. Depending on the packet type, the "To" field can be either a single client address (representing a "Direct Message" of sorts), or another special identifier representing several clients. -For example, the following is a verbatim "Server Identification" message represented as a go string: +For example, the following is a "Server Identification" FSD packet represented as a string: ```go "$DISERVER:CLIENT:VATSIM FSD V3.43:d95f57db664f\r\n" @@ -97,11 +191,9 @@ This includes the source callsign again, the cert ID again, the login token prev ```go -"#TMserver:N12345:Welcome to VATSIM! Need help getting started? Visit https://vats.im/plc for excellent resources.\r\n" +"#TMSERVER:N12345:openfsd\r\n" ``` -VATSIM's FSD implementation sends a lowercase '`server`' identifier for this specific message. I have no idea why this is the case. Inconsistencies like this are common across FSD. - ### Post-Login After the client has logged in, it's generally free to send whatever it wants. This project implements logic for the following packets: diff --git a/auth/fsd_jwt.go b/auth/fsd_jwt.go new file mode 100644 index 0000000..7aca9f7 --- /dev/null +++ b/auth/fsd_jwt.go @@ -0,0 +1,130 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "github.com/golang-jwt/jwt/v5" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "io" + "strconv" + "time" +) + +// FSDJWTRequest represents the vatsim-specific /api/fsd-jwt JSON request payload +type FSDJWTRequest struct { + CID string `json:"cid"` + Password string `json:"password"` +} + +// FSDJWTResponse represents the vatsim-specific /api/fsd-jwt JSON response payload +type FSDJWTResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +// FSDJWTClaims is the standard claims type for openfsd tokens +type FSDJWTClaims struct { + cid int + controllerRating protocol.NetworkRating + pilotRating protocol.PilotRating + audience jwt.ClaimStrings +} + +func (c *FSDJWTClaims) CID() int { + return c.cid +} + +func (c *FSDJWTClaims) ControllerRating() protocol.NetworkRating { + return c.controllerRating +} + +func (c *FSDJWTClaims) PilotRating() protocol.PilotRating { + return c.pilotRating +} + +func (c *FSDJWTClaims) Audience() jwt.ClaimStrings { + return c.audience +} + +func NewFSDJWTClaims(cid int, networkRating protocol.NetworkRating, pilotRating protocol.PilotRating, audience []string) *FSDJWTClaims { + return &FSDJWTClaims{ + cid: cid, + controllerRating: networkRating, + pilotRating: pilotRating, + audience: audience, + } +} + +type FSDJWTCustomClaims struct { + jwt.RegisteredClaims + ControllerRating int `json:"controller_rating"` + PilotRating int `json:"pilot_rating"` +} + +// Parse extracts the specific FSDJWTClaims out of a previously verified token +func (c *FSDJWTClaims) Parse(token *jwt.Token) (err error) { + // Parse CID from subject + var cidStr string + if cidStr, err = token.Claims.GetSubject(); err != nil { + return err + } + if c.cid, err = strconv.Atoi(cidStr); err != nil { + return err + } + + // Parse audience + if c.audience, err = token.Claims.GetAudience(); err != nil { + return err + } + + // Parse vatsim-specific custom claims (controller_rating, pilot_rating) + + mapClaims := token.Claims.(jwt.MapClaims) + + var controllerRatingFloat, pilotRatingFloat float64 + var ok bool + if controllerRatingFloat, ok = mapClaims["controller_rating"].(float64); !ok { + return errors.New("controller_rating claim does not exist") + } + if pilotRatingFloat, ok = mapClaims["pilot_rating"].(float64); !ok { + return errors.New("pilot_rating claim does not exist") + } + + c.controllerRating, c.pilotRating = protocol.NetworkRating(controllerRatingFloat), protocol.PilotRating(pilotRatingFloat) + + return nil +} + +// MakeToken makes a JWT token string for the provided claims +func (c *FSDJWTClaims) MakeToken(expiry time.Time) (token string, err error) { + randomBytes := make([]byte, 16) + if _, err = io.ReadFull(rand.Reader, randomBytes); err != nil { + return "", err + } + id := base64.StdEncoding.EncodeToString(randomBytes) + + now := time.Now() + + t := jwt.NewWithClaims(jwt.SigningMethodHS256, FSDJWTCustomClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "openfsd", + Subject: strconv.Itoa(c.cid), + Audience: c.audience, + ExpiresAt: jwt.NewNumericDate(expiry), + NotBefore: jwt.NewNumericDate(now.Add(-120 * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + ID: id, + }, + ControllerRating: int(c.controllerRating), + PilotRating: int(c.pilotRating), + }) + + if token, err = t.SignedString(servercontext.JWTKey()); err != nil { + return "", err + } + + return +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..2d102dc --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,55 @@ +package auth + +import ( + "errors" + "github.com/golang-jwt/jwt/v5" + "github.com/renorris/openfsd/servercontext" + "time" +) + +// JWTVerifier is an frontend for verifying JWT tokens +type JWTVerifier interface { + VerifyJWT(tokenStr string) (*jwt.Token, error) +} + +// DefaultVerifier is the default implementation of JWTVerifier +type DefaultVerifier struct{} + +// VerifyJWT verifies the signature, issuer, expiration times, and not-before times of a token string +func (d DefaultVerifier) VerifyJWT(tokenStr string) (token *jwt.Token, err error) { + if token, err = jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return servercontext.JWTKey(), nil + }, jwt.WithValidMethods([]string{"HS256"})); err != nil { + return nil, err + } + + var issuer string + if issuer, err = token.Claims.GetIssuer(); err != nil { + return nil, err + } + if issuer != "openfsd" { + return nil, errors.New("issuer != openfsd") + } + + // Verify expiration time + var expirationTime *jwt.NumericDate + if expirationTime, err = token.Claims.GetExpirationTime(); err != nil { + return nil, err + } + + if expirationTime.Before(time.Now()) { + return nil, errors.New("token expired") + } + + // Verify not-before time + var notBeforeTime *jwt.NumericDate + if notBeforeTime, err = token.Claims.GetNotBefore(); err != nil { + return nil, err + } + + if notBeforeTime.After(time.Now()) { + return nil, errors.New("token not yet valid") + } + + return +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..55c9b96 --- /dev/null +++ b/bootstrap/bootstrap.go @@ -0,0 +1,146 @@ +package bootstrap + +import ( + "context" + "errors" + "github.com/renorris/openfsd/bootstrap/service" + "github.com/renorris/openfsd/servercontext" + "reflect" +) + +// Bootstrap bootstraps/manages multiple services running concurrently +type Bootstrap struct { + services []Service + startErrCh chan error + doneErrs []chan error + Done chan struct{} +} + +type Service interface { + // Start is the function call to start a service. + // + // Start should return once the service is healthily running. + // Start should return diligently, blocking for minimal time. + // + // A service is expected to promptly shut itself down on ctx close. + // + // (It is convention that a service runs concurrently on its own, + // using ctx as the signal to eventually shut down.) + // The service must send an error over doneErr when it stops in + // response to the context closing or due to an internal error. + Start(ctx context.Context, doneErr chan<- error) error +} + +// NewDefaultBootstrap makes a new bootstrapper for the default openfsd services +func NewDefaultBootstrap() *Bootstrap { + + servercontext.InitializeServerContextSingleton(servercontext.New()) + + services := []Service{&service.FSDService{}, &service.HTTPService{}, &service.DataFeedService{}} + + if servercontext.Config().InMemoryDB { + services = append(services, &service.InMemoryDatabaseService{}) + } + + return NewBootstrap(services) +} + +func NewBootstrap(services []Service) *Bootstrap { + return &Bootstrap{ + services: services, + startErrCh: make(chan error), + doneErrs: make([]chan error, 0), + Done: make(chan struct{}), + } +} + +// Start starts the bootstrapping process. +// Returns when all services have started successfully. +func (b *Bootstrap) Start(c context.Context) error { + + ctx, cancel := context.WithCancel(c) + + for _, svc := range b.services { + doneErr := make(chan error) + b.doneErrs = append(b.doneErrs, doneErr) + go func(s Service, doneErr chan error) { + b.startErrCh <- s.Start(ctx, doneErr) + }(svc, doneErr) + } + + // Wait until all services finish starting + capturedStartErrs := make([]error, 0) + for range b.services { + var err error + if err = <-b.startErrCh; err != nil { + // Fire cancel so all services spin down + cancel() + } + capturedStartErrs = append(capturedStartErrs, err) + } + + // Start bootstrap monitor + go b.monitor(cancel) + + // Return an error if >0 services ready'd with an error + var errs error + for _, err := range capturedStartErrs { + if err != nil { + errs = errors.Join(errs, err) + } + } + + if errs != nil { + return errs + } + + return nil +} + +func (b *Bootstrap) monitor(cancel func()) { + + // Dynamically select the doneErr channel from each service. + // If a signal is received, check the error. + // If non-nil, spin down the other services. + // If nil, noop. + // Once the error from each service has been + // captured, signal that we're closed and return. + + cases := make([]reflect.SelectCase, len(b.doneErrs)) + for i, ch := range b.doneErrs { + cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)} + } + + capturedDoneSigs := make([]error, 0) + + for { + i, val, ok := reflect.Select(cases) + if !ok { + // Remove this channel from the case list + cases[i] = cases[len(cases)-1] + cases = cases[:len(cases)-1] + continue + } + + var err error + if val.IsNil() { + err = nil + } else { + err = val.Interface().(error) + } + capturedDoneSigs = append(capturedDoneSigs, err) + + // Spin down all services if this service returned an error + if err != nil { + cancel() + } + + // Check if all services have returned + if len(capturedDoneSigs) == len(b.doneErrs) { + // Mark the completion of this bootstrapping + // process by closing the done channel. + close(b.Done) + return + } + } +} diff --git a/bootstrap/service/datafeed.go b/bootstrap/service/datafeed.go new file mode 100644 index 0000000..987bf99 --- /dev/null +++ b/bootstrap/service/datafeed.go @@ -0,0 +1,191 @@ +package service + +import ( + "context" + "encoding/json" + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "log" + "time" +) + +// DataFeedService polls the server post office every 15 seconds and +// generates a JSON string representing the server state. Conventionally, +// this file is obtained via the HTTP endpoint at /data/openfsd-data.json +type DataFeedService struct{} + +type general struct { + Version int `json:"version"` // Major version of the data feed + UpdateTimestamp time.Time `json:"update_timestamp"` // The last time the data feed was updated + ConnectedClients int `json:"connected_clients"` // Number of clients connected + UniqueUsers int `json:"unique_users"` // Number of unique users connected +} + +type pilot struct { + Callsign string `json:"callsign"` + CID int `json:"cid"` + Name string `json:"name"` + PilotRating protocol.PilotRating `json:"pilot_rating"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude int `json:"altitude"` + Groundspeed int `json:"groundspeed"` + Transponder string `json:"transponder"` + Heading int `json:"heading"` // Degrees magnetic + LastUpdated time.Time `json:"last_updated"` // The time this pilot's information was last updated +} + +type controllerRating struct { + ID protocol.NetworkRating `json:"id"` // Controller NetworkRating ID + ShortName string `json:"short_name"` // Short identifier + LongName string `json:"long_name"` // Human-readable long name +} + +type pilotRating struct { + ID protocol.PilotRating `json:"id"` // pilot NetworkRating ID + ShortName string `json:"short_name"` // Short identifier + LongName string `json:"long_name"` // Human-readable long name +} + +type schema struct { + General general `json:"general"` + Pilots []pilot `json:"pilots"` + Ratings []controllerRating `json:"ratings"` + PilotRatings []pilotRating `json:"pilot_ratings"` +} + +func (s *DataFeedService) Start(ctx context.Context, doneErr chan<- error) (err error) { + + readySig := make(chan struct{}) + + // boot data feed service on its own goroutine + go func() { + doneErr <- s.boot(ctx, readySig) + }() + + // Wait for the ready signal + <-readySig + log.Println("Data feed service running.") + + return nil +} + +func (s *DataFeedService) boot(ctx context.Context, readySig chan struct{}) error { + + // Run a tick first + if err := s.tick(); err != nil { + close(readySig) + return err + } + + close(readySig) + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + // Loop until context close. On tick, update the feed. + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := s.tick(); err != nil { + return err + } + } + } +} + +func (s *DataFeedService) tick() error { + // Load all pilot data from post office + numRegistered := servercontext.PostOffice().NumRegistered() + pilots := make([]pilot, 0, numRegistered+32) + + uniqueCIDs := make(map[int]*interface{}, numRegistered+32) + + servercontext.PostOffice().ForEachRegistered(func(name string, address postoffice.Address) bool { + state := address.State() + + // Build pilot data + p := pilot{ + Callsign: name, + CID: state.CID, + Name: state.RealName, + PilotRating: state.PilotRating, + Latitude: state.Latitude, + Longitude: state.Longitude, + Altitude: state.Altitude, + Groundspeed: state.Groundspeed, + Transponder: state.Transponder, + Heading: state.Heading, + LastUpdated: state.LastUpdated.UTC(), + } + + // Append it to the list + pilots = append(pilots, p) + + // Add this CID to the unique CID list + uniqueCIDs[state.CID] = nil + + return true + }) + + // Build the data feed + feed := schema{ + General: general{ + Version: 0, + UpdateTimestamp: time.Now().UTC(), + ConnectedClients: len(pilots), + UniqueUsers: len(uniqueCIDs), + }, + Pilots: pilots, + Ratings: s.makeControllerRatingList(), + PilotRatings: s.makePilotRatingsList(), + } + + return s.setFeed(feed) +} + +func (s *DataFeedService) setFeed(feed schema) (err error) { + + // Marshal result + var feedBytes []byte + if feedBytes, err = json.Marshal(feed); err != nil { + return err + } + + // Set the new feed + d := servercontext.DataFeed() + d.SetFeed(string(feedBytes), feed.General.UpdateTimestamp) + + return nil +} + +func (s *DataFeedService) makeControllerRatingList() (ratings []controllerRating) { + ratings = make([]controllerRating, 0, 14) + + protocol.ForEachNetworkRating(func(id protocol.NetworkRating, shortString string, longString string) { + ratings = append(ratings, controllerRating{ + ID: id, + ShortName: shortString, + LongName: longString, + }) + }) + + return ratings +} + +func (s *DataFeedService) makePilotRatingsList() (ratings []pilotRating) { + ratings = make([]pilotRating, 0, 7) + + protocol.ForEachPilotRating(func(id protocol.PilotRating, shortString string, longString string) { + ratings = append(ratings, pilotRating{ + ID: id, + ShortName: shortString, + LongName: longString, + }) + }) + + return ratings +} diff --git a/bootstrap/service/fsd.go b/bootstrap/service/fsd.go new file mode 100644 index 0000000..d3fea9a --- /dev/null +++ b/bootstrap/service/fsd.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + "errors" + "github.com/renorris/openfsd/client" + "github.com/renorris/openfsd/servercontext" + "log" + "net" + "os" + "slices" + "sync" +) + +type FSDService struct{} + +func (s *FSDService) Start(ctx context.Context, doneErr chan<- error) (err error) { + + // Set "AlwaysImmediate" to true in test cases + if slices.Contains(os.Environ(), "ALWAYS_IMMEDIATE=true") { + client.AlwaysImmediate = true + } + + // Attempt to listen. Once a listener is acquired, we can guarantee that clients can connect. + var listener *net.TCPListener + if listener, err = s.resolveAndListen(); err != nil { + return err + } + + log.Printf("FSD server listening on %s", servercontext.Config().FSDListenAddress) + + // boot FSD on its own goroutine + go func(ctx context.Context, doneErr chan<- error, listener *net.TCPListener) { + doneErr <- s.boot(ctx, listener) + }(ctx, doneErr, listener) + + return nil +} + +func (s *FSDService) boot(ctx context.Context, listener *net.TCPListener) error { + defer listener.Close() + + defer log.Println("FSD server shutting down...") + + incomingConns := make(chan *net.TCPConn) + go acceptorWorker(ctx, listener, incomingConns) + + // Track each client connection in a wait group + waitGroup := sync.WaitGroup{} + + for { + select { + // Handle incoming connections + case conn, ok := <-incomingConns: + if !ok { + return errors.New("listener closed") + } + + waitGroup.Add(1) + go func(ctx context.Context, conn *net.TCPConn) { + defer waitGroup.Done() + + // Recover from a panic + defer func() { + if err := recover(); err != nil { + log.Println("panic occurred:", err) + } + }() + + var connection *client.Connection + var err error + if connection, err = client.NewConnection(ctx, conn); err != nil { + return + } + + connection.Start() + }(ctx, conn) + + // Wait for cleanup on context done + case <-ctx.Done(): + waitGroup.Wait() + return nil + } + } +} + +func acceptorWorker(ctx context.Context, listener *net.TCPListener, conns chan<- *net.TCPConn) { + defer close(conns) + for { + conn, err := listener.AcceptTCP() + if err != nil { + return + } + + select { + case conns <- conn: + case <-ctx.Done(): + return + } + } +} + +func (s *FSDService) resolveAndListen() (listener *net.TCPListener, err error) { + addr, err := net.ResolveTCPAddr("tcp4", servercontext.Config().FSDListenAddress) + if err != nil { + return nil, err + } + + listener, err = net.ListenTCP("tcp4", addr) + if err != nil { + log.Println(err) + return nil, err + } + + return listener, nil +} diff --git a/bootstrap/service/http.go b/bootstrap/service/http.go new file mode 100644 index 0000000..d4ecbee --- /dev/null +++ b/bootstrap/service/http.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/servercontext" + "github.com/renorris/openfsd/web" + "log" + "net" + "net/http" +) + +type HTTPService struct{} + +func (s *HTTPService) Start(ctx context.Context, doneErr chan<- error) (err error) { + + // Attempt to listen. Once a listener is acquired, we can guarantee that clients can connect. + var listener net.Listener + if listener, err = net.Listen("tcp", servercontext.Config().HTTPListenAddress); err != nil { + return err + } + + log.Printf("HTTP server listening on %s", servercontext.Config().HTTPListenAddress) + + // boot http server on its own goroutine + go func(ctx context.Context, doneErr chan<- error, listener net.Listener) { + doneErr <- s.boot(ctx, listener) + }(ctx, doneErr, listener) + + return nil +} + +func (s *HTTPService) boot(ctx context.Context, listener net.Listener) (err error) { + defer listener.Close() + + defer log.Println("HTTP server shutting down...") + + mux := http.NewServeMux() + + // token provider + mux.HandleFunc("POST /api/v1/fsd-jwt", web.FSDJWTHandler) + + // api/v1 users + mux.HandleFunc("GET /api/v1/users/{cid}", func(w http.ResponseWriter, r *http.Request) { + web.APIV1UsersHandler(w, r, auth.DefaultVerifier{}) + }) + mux.HandleFunc("/api/v1/users", func(w http.ResponseWriter, r *http.Request) { + web.APIV1UsersHandler(w, r, auth.DefaultVerifier{}) + }) + + // data feed + mux.HandleFunc("GET /api/v1/data/openfsd-data.json", web.DataFeedHandler) + + // favicon + mux.HandleFunc("/favicon.ico", web.FaviconHandler) + + // static files + mux.Handle("/static/", http.FileServerFS(web.StaticFS)) + + // Interface UI handler (catch-all) + mux.HandleFunc("/", web.FrontendHandler) + + httpServer := &http.Server{ + Addr: servercontext.Config().HTTPListenAddress, + Handler: mux, + } + + errCh := make(chan error) + go func() { + if servercontext.Config().TLSCertFile != "" && servercontext.Config().TLSKeyFile != "" { + errCh <- httpServer.ServeTLS(listener, servercontext.Config().TLSCertFile, servercontext.Config().TLSKeyFile) + } else { + errCh <- httpServer.Serve(listener) + } + }() + + select { + case <-ctx.Done(): + return httpServer.Close() + case err = <-errCh: + return err + } +} diff --git a/bootstrap/service/memory_db.go b/bootstrap/service/memory_db.go new file mode 100644 index 0000000..8ce868f --- /dev/null +++ b/bootstrap/service/memory_db.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + sqle "github.com/dolthub/go-mysql-server" + "github.com/dolthub/go-mysql-server/memory" + "github.com/dolthub/go-mysql-server/server" + "github.com/fatih/color" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/servercontext" + "log" + "net" +) + +type InMemoryDatabaseService struct{} + +func (s *InMemoryDatabaseService) Start(ctx context.Context, doneErr chan<- error) error { + ready := make(chan struct{}) + + go func(ctx context.Context, doneErr chan<- error, ready chan struct{}) { + doneErr <- s.boot(ctx, ready) + }(ctx, doneErr, ready) + + // wait for ready signal + <-ready + + // Initialize database + return database.Initialize(servercontext.DB()) +} + +func (s *InMemoryDatabaseService) boot(ctx context.Context, ready chan struct{}) (err error) { + db := memory.NewDatabase("openfsd") + db.BaseDatabase.EnablePrimaryKeyIndexes() + + pro := memory.NewDBProvider(db) + engine := sqle.NewDefault(pro) + + var listener net.Listener + if listener, err = net.Listen("tcp", "127.0.0.1:33060"); err != nil { + close(ready) + return err + } + + close(ready) + + config := server.Config{ + Protocol: "tcp", + Listener: listener, + } + + log.Println(color.YellowString("WARNING: ") + "using an ephemeral in-memory database. This should only be used for testing. All data will be lost when the process ends.") + log.Printf("In-memory MySQL server listening on 127.0.0.1:33060") + defer log.Println("In-memory MySQL server shutting down...") + + var srv *server.Server + if srv, err = server.NewServer(config, engine, memory.NewSessionBuilder(pro), nil); err != nil { + return err + } + + errCh := make(chan error) + go func() { errCh <- srv.Start() }() + + select { + case <-ctx.Done(): + return srv.Close() + case err = <-errCh: + return err + } +} diff --git a/client/connection.go b/client/connection.go new file mode 100644 index 0000000..f7ba899 --- /dev/null +++ b/client/connection.go @@ -0,0 +1,393 @@ +package client + +import ( + "bufio" + "context" + "errors" + "github.com/golang-jwt/jwt/v5" + auth2 "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/protocol/vatsimauth" + "github.com/renorris/openfsd/servercontext" + "golang.org/x/crypto/bcrypt" + "log" + "net" + "strconv" + "strings" + "time" +) + +const maxFSDPacketSize = 1536 +const writeFlushInterval = 200 * time.Millisecond + +var AlwaysImmediate = false + +type writePayload struct { + packet string + immediate bool +} + +type Connection struct { + ctx context.Context + cancelCtx func() + conn *net.TCPConn + + readerChan chan string + writerChan chan writePayload + + doneSig chan struct{} +} + +func NewConnection(ctx context.Context, conn *net.TCPConn) (*Connection, error) { + // Ensure any lingering data will be flushed + if err := conn.SetLinger(1); err != nil { + return nil, err + } + + c, cancel := context.WithCancel(ctx) + + connection := Connection{ + ctx: c, + cancelCtx: cancel, + conn: conn, + + readerChan: make(chan string), + writerChan: make(chan writePayload), + + doneSig: make(chan struct{}), + } + + // Start reader + go func() { + defer connection.cancelCtx() + + if err := connection.reader(); err != nil { + // Attempt to send the error to the client if it implements FSDError + var fsdError *protocol.FSDError + if errors.As(err, &fsdError) { + connection.WritePacket(fsdError.Serialize()) + } + } + }() + + // Start writer + go func() { + defer close(connection.doneSig) + defer connection.cancelCtx() + + if err := connection.writer(); err != nil { + // TODO: properly handle error + } + }() + + return &connection, nil +} + +func (c *Connection) writePacket(packet string, immediate bool) error { + + payload := writePayload{ + packet: packet, + immediate: immediate, + } + + select { + case <-c.ctx.Done(): + return errors.New("context closed") + case c.writerChan <- payload: + return nil + } +} + +func (c *Connection) WritePacket(packet string) error { + return c.writePacket(packet, AlwaysImmediate) +} + +func (c *Connection) WritePacketImmediately(packet string) error { + return c.writePacket(packet, true) +} + +func (c *Connection) ReadPacket() (packet string, err error) { + select { + case <-c.ctx.Done(): + return "", errors.New("context closed") + case packet = <-c.readerChan: + return packet, nil + } +} + +func (c *Connection) reader() error { + + reader := bufio.NewReaderSize(c.conn, maxFSDPacketSize) + + for { + if err := c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + // TODO: properly handle error + return err + } + + var packet string + + if buf, err := reader.ReadSlice('\n'); err != nil { + return err + } else if len(buf) > maxFSDPacketSize { + return protocol.NewGenericFSDError(protocol.SyntaxError, "", "packet too long") + } else if !strings.HasSuffix(string(buf), protocol.PacketDelimiter) { + return protocol.NewGenericFSDError(protocol.SyntaxError, "", "invalid packet delimeter") + } else { + // Copy the buffer since ReadSlice() will overwrite it eventually. + bufCopy := make([]byte, len(buf)) + copy(bufCopy, buf) + packet = string(buf) + } + + // Send the packet over the channel + select { + case <-c.ctx.Done(): + return nil + case c.readerChan <- packet: + } + } +} + +func (c *Connection) writer() error { + + writer := bufio.NewWriter(c.conn) + defer writer.Flush() + + ticker := time.NewTicker(writeFlushInterval) + defer ticker.Stop() + ticker.Stop() + tickerActive := false + + for { + // Read incoming packet + select { + case <-c.ctx.Done(): + return nil + case payload, ok := <-c.writerChan: + if !ok { + return nil + } + + if err := c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + // TODO: properly handle error + return err + } + + if _, err := writer.WriteString(payload.packet); err != nil { + // TODO: properly handle error + return err + } + + if payload.immediate { + if err := writer.Flush(); err != nil { + // TODO: properly handle error + return err + } + } + + if !tickerActive { + tickerActive = true + ticker.Reset(writeFlushInterval) + } + + case <-ticker.C: + if err := c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + // TODO: properly handle error + return err + } + + if err := writer.Flush(); err != nil { + // TODO: properly handle error + return err + } + + ticker.Stop() + tickerActive = false + } + } +} + +// Start handles the logical lifetime of this connection +func (c *Connection) Start() { + // Ensure connection is closed when finished + defer c.conn.Close() + + // Wait until connection state cleans up before closing connection + defer func() { + <-c.doneSig + }() + + // Ensure context is always cancelled + defer c.cancelCtx() + + // Attempt to login + var fsdClient *FSDClient + var err error + if fsdClient, err = c.attemptLogin(); err != nil { + var fsdError *protocol.FSDError + if errors.As(err, &fsdError) { + c.WritePacketImmediately(fsdError.Serialize()) + } + return + } + + // Register to post office + if err = servercontext.PostOffice().RegisterAddress(fsdClient); err != nil { + if errors.Is(err, postoffice.KeyInUseError) { + c.WritePacketImmediately(protocol.NewGenericFSDError(protocol.CallsignInUseError, fsdClient.callsign, "").Serialize()) + } + return + } + defer servercontext.PostOffice().DeregisterAddress(fsdClient) + + // Broadcast add pilot message + fsdClient.broadcastAddPilot() + defer fsdClient.broadcastDeletePilot() + + if err = fsdClient.sendMOTD(); err != nil { + return + } + + // Now that we're logged in, run the event loop until an error occurs + fsdClient.EventLoop() +} + +// attemptLogin attempts to log in the connection +func (c *Connection) attemptLogin() (fsdClient *FSDClient, err error) { + // Generate the initial challenge + var initChallenge string + if initChallenge, err = vatsimauth.GenerateChallenge(); err != nil { + log.Println("error calling vatsimauth.GenerateChallenge(): " + err.Error()) + fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error generating initial challenge)") + return nil, fsdErr + } + + // Generate server identification packet + serverIdentPDU := protocol.ServerIdentificationPDU{ + From: protocol.ServerCallsign, + To: "CLIENT", + Version: servercontext.VersionIdentifier, + InitialChallenge: initChallenge, + } + serverIdentPacket := serverIdentPDU.Serialize() + + // Write server identification packet + if err = c.WritePacketImmediately(serverIdentPacket); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error writing $DI server identification packet)") + return nil, fsdErr + } + + // Read the first expected packet: client identification + var packet string + if packet, err = c.ReadPacket(); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading $ID client identification packet") + return nil, fsdErr + } + + // Parse it + var clientIdentPDU protocol.ClientIdentificationPDU + if err = clientIdentPDU.Parse(packet); err != nil { + return nil, err + } + + // Read the second expected packet: add pilot + if packet, err = c.ReadPacket(); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading #AP add pilot packet") + return nil, fsdErr + } + + var addPilotPDU protocol.AddPilotPDU + if err = addPilotPDU.Parse(packet); err != nil { + return nil, err + } + + // Handle authentication + var networkRating protocol.NetworkRating + var pilotRating protocol.PilotRating + if servercontext.Config().PlaintextPasswords { // Treat token field as a plaintext password + if networkRating, pilotRating, err = verifyPassword(clientIdentPDU.CID, addPilotPDU.Token); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid CID and/or password") + return nil, fsdErr + } + + // Check if the account is suspended or inactive + if addPilotPDU.NetworkRating <= protocol.NetworkRatingSUS { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, strconv.Itoa(int(addPilotPDU.NetworkRating)), "account suspended/inactive") + return nil, fsdErr + } + + // Check if the requested PDU rating exceeds their user record + if addPilotPDU.NetworkRating > networkRating { + fsdErr := protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, strconv.Itoa(int(addPilotPDU.NetworkRating)), "try again at or below your assigned rating") + return nil, fsdErr + } + + } else { // Treat token field as a JWT token + var token *jwt.Token + var verifier auth2.DefaultVerifier + if token, err = verifier.VerifyJWT(addPilotPDU.Token); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token") + return nil, fsdErr + } + + claims := auth2.FSDJWTClaims{} + if err = claims.Parse(token); err != nil { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims") + return nil, fsdErr + } + + if claims.CID() != clientIdentPDU.CID { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims (CID)") + return nil, fsdErr + } + + if claims.ControllerRating() < addPilotPDU.NetworkRating { + fsdErr := protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, strconv.Itoa(int(claims.ControllerRating())), "try again at or below your assigned rating") + return nil, fsdErr + } + + networkRating = claims.ControllerRating() + pilotRating = claims.PilotRating() + } + + // Check for disallowed callsign + switch clientIdentPDU.From { + case protocol.ServerCallsign, protocol.ClientQueryBroadcastRecipient, protocol.ClientQueryBroadcastRecipientPilots: + fsdErr := protocol.NewGenericFSDError(protocol.CallsignInvalidError, clientIdentPDU.From, "forbidden callsign") + return nil, fsdErr + } + + // Verify protocol revision + if addPilotPDU.ProtocolRevision != protocol.ProtoRevisionVatsim2022 { + fsdErr := protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError, strconv.Itoa(addPilotPDU.ProtocolRevision), "please connect with a client that supports the Vatsim2022 (101) protocol revision") + return nil, fsdErr + } + + // Verify if this browser is supported by vatsimauth + if _, ok := vatsimauth.Keys[clientIdentPDU.ClientID]; !ok { + fsdErr := protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError, "", "provided client ID is not supported by vatsimauth") + return nil, fsdErr + } + + fsdClient = NewFSDClient(c, &clientIdentPDU, &addPilotPDU, initChallenge, pilotRating) + + return fsdClient, nil +} + +// verifyPassword verifies a password in the cases when PLAINTEXT_PASSWORDS is in use. +func verifyPassword(cid int, password string) (networkRating protocol.NetworkRating, pilotRating protocol.PilotRating, err error) { + var userRecord database.FSDUserRecord + if err = userRecord.LoadByCID(servercontext.DB(), cid); err != nil { + return -1, -1, err + } + + err = bcrypt.CompareHashAndPassword([]byte(userRecord.FSDPassword), []byte(password)) + if err != nil { + return -1, -1, err + } + + return userRecord.NetworkRating, userRecord.PilotRating, nil +} diff --git a/client/event_loop.go b/client/event_loop.go new file mode 100644 index 0000000..97fa057 --- /dev/null +++ b/client/event_loop.go @@ -0,0 +1,100 @@ +package client + +import ( + "errors" + "fmt" + "github.com/renorris/openfsd/handler" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "log" +) + +// EventLoop runs the main event loop for a logged in client +func (c *FSDClient) EventLoop() error { + + infoStr := fmt.Sprintf("callsign=%s cid=%d rating=%s name=\"%s\" ip=%s", c.callsign, c.cid, c.networkRating.String(), c.realName, c.RemoteNetworkAddrString()) + log.Printf("client_connected total_clients=%d %s", servercontext.PostOffice().NumRegistered(), infoStr) + defer func() { + log.Printf("client_disconnected total_clients=%d %s", servercontext.PostOffice().NumRegistered()-1, infoStr) + }() + + // Post-login FSD client event loop + for { + select { + + // Close on context elapse + case <-c.connection.ctx.Done(): + return nil + + // Handle incoming packets + case packet := <-c.connection.readerChan: + if shouldDisconnect, err := c.handleIncomingPacket(packet); err != nil { + return err + } else if shouldDisconnect { + return nil + } + + // Handle incoming mail + case mailPacket := <-c.mailbox: + if err := c.connection.WritePacket(mailPacket); err != nil { + return err + } + + // Handle incoming kill signals + case killPacket := <-c.kill: + return c.connection.WritePacketImmediately(killPacket) + } + } +} + +func (c *FSDClient) handleIncomingPacket(packet string) (shouldDisconnect bool, err error) { + // Find a handler for this packet + var h handler.Handler + if h, err = handler.New(packet); err != nil { + // Check if the error is an FSD error. + // If so, gracefully send it to the client. If not, return. + var fsdError *protocol.FSDError + if errors.As(err, &fsdError) { + if err = c.connection.WritePacket(fsdError.Serialize()); err != nil { + return + } + } + return + } + + // Run the handler function + var result handler.Result + if result, err = h(c, packet); err != nil { + // Check if the error is an FSD error. + // If so, gracefully send it to the client. If not, return. + var fsdError *protocol.FSDError + if errors.As(err, &fsdError) { + if err = c.connection.WritePacket(fsdError.Serialize()); err != nil { + return + } + } else { + return + } + } + + // Send replies + if replies := result.Replies(); replies != nil { + for _, r := range replies { + if err = c.connection.WritePacket(r); err != nil { + return + } + } + } + + // Send mail + if mailingList := result.MailingList(); mailingList != nil { + for _, mail := range mailingList { + servercontext.PostOffice().SendMail(&mail) + } + } + + // disconnect if flagged + shouldDisconnect = result.DisconnectFlag() + + return +} diff --git a/client/fsd_client.go b/client/fsd_client.go new file mode 100644 index 0000000..e7c052b --- /dev/null +++ b/client/fsd_client.go @@ -0,0 +1,178 @@ +package client + +import ( + "errors" + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/protocol/vatsimauth" +) + +type FSDClient struct { + connection *Connection + + authVerify *vatsimauth.VatsimAuth // Auth state to verify browser's auth responses + pendingChallenge string // store the pending challenge sent to the browser + authSelf *vatsimauth.VatsimAuth // Auth state for interrogating browser + + // General information + callsign string + cid int + networkRating protocol.NetworkRating + pilotRating protocol.PilotRating + realName string + simulatorType int + + spatialState *spatialState + + currentGeohash postoffice.Geohash + sendFastEnabled bool + + kill chan string // Signal to disconnect this client + mailbox chan string // Incoming messages +} + +func NewFSDClient(connection *Connection, clientIdentPDU *protocol.ClientIdentificationPDU, + addPilotPDU *protocol.AddPilotPDU, initialServerChallenge string, pilotRating protocol.PilotRating) *FSDClient { + + client := FSDClient{ + connection: connection, + + authVerify: vatsimauth.NewVatsimAuth( + clientIdentPDU.ClientID, + vatsimauth.Keys[clientIdentPDU.ClientID]), + authSelf: vatsimauth.NewVatsimAuth( + clientIdentPDU.ClientID, + vatsimauth.Keys[clientIdentPDU.ClientID]), + + callsign: clientIdentPDU.From, + cid: clientIdentPDU.CID, + networkRating: addPilotPDU.NetworkRating, + pilotRating: pilotRating, + simulatorType: addPilotPDU.SimulatorType, + realName: addPilotPDU.RealName, + + spatialState: &spatialState{}, + + kill: make(chan string, 1), + mailbox: make(chan string, 32), + } + + client.authSelf.SetInitialChallenge(clientIdentPDU.InitialChallenge) + client.authVerify.SetInitialChallenge(initialServerChallenge) + + return &client +} + +// postoffice.Address implementations: + +func (c *FSDClient) Name() string { + return c.callsign +} + +func (c *FSDClient) SendMail(packet string) { + // Non-blocking send + select { + case c.mailbox <- packet: + default: + } +} + +func (c *FSDClient) SendKill(packet string) error { + // Non-blocking send + select { + case c.kill <- packet: + return nil + default: + return errors.New("client unavailable") + } +} + +func (c *FSDClient) NetworkRating() protocol.NetworkRating { + return c.networkRating +} + +func (c *FSDClient) Geohash() postoffice.Geohash { + return c.currentGeohash +} + +func (c *FSDClient) State() postoffice.AddressState { + state := postoffice.AddressState{ + CID: c.cid, + RealName: c.realName, + PilotRating: c.pilotRating, + } + + c.spatialState.lock.RLock() + + state.Latitude = c.spatialState.latitude + state.Longitude = c.spatialState.longitude + state.Altitude = c.spatialState.altitude + state.Groundspeed = c.spatialState.groundspeed + state.Transponder = c.spatialState.transponder + state.Heading = c.spatialState.heading + state.LastUpdated = c.spatialState.lastUpdated + + c.spatialState.lock.RUnlock() + + return state +} + +func (c *FSDClient) SetAddressState(state *postoffice.AddressState) { + c.spatialState.lock.Lock() + + c.spatialState.latitude = state.Latitude + c.spatialState.longitude = state.Longitude + c.spatialState.groundspeed = state.Groundspeed + c.spatialState.altitude = state.Altitude + c.spatialState.heading = state.Heading + c.spatialState.transponder = state.Transponder + c.spatialState.lastUpdated = state.LastUpdated + + c.spatialState.lock.Unlock() +} + +// handler.Invoker implementations: + +func (c *FSDClient) Callsign() string { + return c.callsign +} + +func (c *FSDClient) AuthSelf() *vatsimauth.VatsimAuth { + return c.authSelf +} + +func (c *FSDClient) AuthVerify() *vatsimauth.VatsimAuth { + return c.authVerify +} + +func (c *FSDClient) PendingChallenge() string { + return c.pendingChallenge +} + +func (c *FSDClient) SetPendingChallenge(s string) { + c.pendingChallenge = s +} + +func (c *FSDClient) CID() int { + return c.cid +} + +func (c *FSDClient) SetGeohash(h postoffice.Geohash) { + c.currentGeohash = h +} + +func (c *FSDClient) SendFastEnabled() bool { + return c.sendFastEnabled +} + +func (c *FSDClient) SetSendFastEnabled(b bool) { + c.sendFastEnabled = b +} + +func (c *FSDClient) Address() postoffice.Address { + return c +} + +func (c *FSDClient) RemoteNetworkAddrString() string { + return c.connection.conn.RemoteAddr().String() +} diff --git a/client/spatial_state.go b/client/spatial_state.go new file mode 100644 index 0000000..03d8a2e --- /dev/null +++ b/client/spatial_state.go @@ -0,0 +1,36 @@ +package client + +import ( + "sync" + "time" +) + +type spatialState struct { + lock sync.RWMutex + + latitude float64 + longitude float64 + altitude int + groundspeed int + transponder string + heading int + lastUpdated time.Time +} + +func (s *spatialState) update( + latitude, longitude float64, + altitude, groundspeed, heading int, + transponder string) { + + s.lock.Lock() + + s.latitude = latitude + s.longitude = longitude + s.altitude = altitude + s.groundspeed = groundspeed + s.transponder = transponder + s.heading = heading + s.lastUpdated = time.Now() + + s.lock.Unlock() +} diff --git a/client/util.go b/client/util.go new file mode 100644 index 0000000..9471a92 --- /dev/null +++ b/client/util.go @@ -0,0 +1,47 @@ +package client + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "strings" +) + +func (c *FSDClient) sendMOTD() error { + lines := strings.Split(servercontext.Config().MOTD, "\n") + for _, line := range lines { + pdu := protocol.TextMessagePDU{ + From: protocol.ServerCallsign, + To: c.callsign, + Message: line, + } + if err := c.connection.WritePacket(pdu.Serialize()); err != nil { + return err + } + } + + return nil +} + +func (c *FSDClient) broadcastAddPilot() { + p := protocol.AddPilotPDU{ + From: c.callsign, + To: protocol.ServerCallsign, + CID: c.cid, + NetworkRating: c.networkRating, + ProtocolRevision: protocol.ProtoRevisionVatsim2022, + SimulatorType: c.simulatorType, + RealName: c.realName, + } + mail := postoffice.NewMail(c, postoffice.MailTypeBroadcast, "", p.Serialize()) + servercontext.PostOffice().SendMail(&mail) +} + +func (c *FSDClient) broadcastDeletePilot() { + deletePilotPDU := protocol.DeletePilotPDU{ + From: c.callsign, + CID: c.cid, + } + mail := postoffice.NewMail(c, postoffice.MailTypeBroadcast, "", deletePilotPDU.Serialize()) + servercontext.PostOffice().SendMail(&mail) +} diff --git a/dashboard.html b/dashboard.html deleted file mode 100644 index 63fc831..0000000 --- a/dashboard.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - FSD - - - - - - - -

FSD

- -
- -
-

Get user

-
- - - -
- -
-
-
-

Create user

-
- - - - - - - -
- -
-
- - - \ No newline at end of file diff --git a/database/init.go b/database/init.go new file mode 100644 index 0000000..f37199a --- /dev/null +++ b/database/init.go @@ -0,0 +1,102 @@ +package database + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "github.com/renorris/openfsd/protocol" + "io" + "log" + "time" +) + +const createUsersTableStatement = ` +CREATE TABLE IF NOT EXISTS users ( + cid INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255), + first_name VARCHAR(255), + last_name VARCHAR(255), + password CHAR(60) NOT NULL, + fsd_password CHAR(60) NOT NULL, + network_rating INT NOT NULL, + pilot_rating INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) +` + +// Initialize initializes a database `db` +func Initialize(db *sql.DB) error { + if err := initializeUserTable(db); err != nil { + return err + } + + return nil +} + +func initializeUserTable(db *sql.DB) error { + ctx, cancelCtx := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelCtx() + + // Execute create users table if not exists + var err error + if _, err = db.ExecContext(ctx, createUsersTableStatement); err != nil { + return err + } + + // Set auto_increment to the initial CID value + if _, err = db.ExecContext(ctx, "ALTER TABLE users AUTO_INCREMENT 100000"); err != nil { + return err + } + + // Check if the table is empty + var emptyCheckStmt *sql.Stmt + if emptyCheckStmt, err = db.PrepareContext(ctx, "SELECT EXISTS (SELECT 1 FROM users)"); err != nil { + return err + } + + var exists int64 + if err = emptyCheckStmt.QueryRowContext(ctx).Scan(&exists); err != nil { + return err + } + if exists == 0 { + // Generate a default user if the table is empty + randBytes := make([]byte, 32) + if _, err = io.ReadFull(rand.Reader, randBytes); err != nil { + return err + } + + primaryPassword := hex.EncodeToString(randBytes[0:16]) + fsdPassword := hex.EncodeToString(randBytes[16:32]) + + defaultUser := FSDUserRecord{ + FirstName: "Default Administrator", + Password: primaryPassword, + FSDPassword: fsdPassword, + NetworkRating: protocol.NetworkRatingADM, + PilotRating: protocol.PilotRatingFE, + } + + var cid int + if cid, err = defaultUser.Insert(db); err != nil { + return err + } + + log.Printf(` + + DEFAULT ADMINISTRATOR USER: + CID: %d + PRIMARY PASSWORD: %s + FSD PASSWORD: %s + +`, cid, primaryPassword, fsdPassword) + + var testRecord FSDUserRecord + if err = testRecord.LoadByCID(db, cid); err != nil { + return err + } + } + + return nil +} diff --git a/database/users.go b/database/users.go new file mode 100644 index 0000000..8d1eca9 --- /dev/null +++ b/database/users.go @@ -0,0 +1,193 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "github.com/renorris/openfsd/protocol" + "golang.org/x/crypto/bcrypt" + "time" +) + +type FSDUserRecord struct { + CID int `json:"cid"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Password string `json:"password,omitempty"` + FSDPassword string `json:"fsd_password,omitempty"` + NetworkRating protocol.NetworkRating `json:"network_rating"` + PilotRating protocol.PilotRating `json:"pilot_rating"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func noRowsChangedError() error { return errors.New("no rows changed") } + +var NoRowsChangedError = noRowsChangedError() + +// Update updates this record in the database `db`. +// CID is immutable: it must reference the account to update. +// All values must be set except for password and fsd_password, which are optional. +// Automatically hashes provided passwords, which must be in plaintext. +func (r *FSDUserRecord) Update(db *sql.DB) (err error) { + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + var stmt *sql.Stmt + // Prepare the initial statement + if stmt, err = db.PrepareContext(ctx, "UPDATE users SET email=?, first_name=?, last_name=?, network_rating=?, pilot_rating=? WHERE cid=?"); err != nil { + return err + } + + if _, err = stmt.ExecContext(ctx, r.Email, r.FirstName, r.LastName, int(r.NetworkRating), r.PilotRating, r.CID); err != nil { + return err + } + + if err = stmt.Close(); err != nil { + return err + } + + // Update passwords if necessary + if r.Password != "" { + var passwordHash []byte + if passwordHash, err = bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.MinCost); err != nil { + return err + } + + if stmt, err = db.PrepareContext(ctx, "UPDATE users SET password=? WHERE cid=?"); err != nil { + return err + } + + if _, err = stmt.ExecContext(ctx, string(passwordHash), r.CID); err != nil { + return err + } + + if err = stmt.Close(); err != nil { + return err + } + } + + if r.FSDPassword != "" { + var fsdPasswordHash []byte + if fsdPasswordHash, err = bcrypt.GenerateFromPassword([]byte(r.FSDPassword), bcrypt.MinCost); err != nil { + return err + } + + if stmt, err = db.PrepareContext(ctx, "UPDATE users SET fsd_password=? WHERE cid=?"); err != nil { + return err + } + + if _, err = stmt.ExecContext(ctx, string(fsdPasswordHash), r.CID); err != nil { + return err + } + + if err = stmt.Close(); err != nil { + return err + } + } + + return nil +} + +// LoadByCID loads a user with the primary key `cid` from the database `db` +// Returns sql.ErrNoRows when no record matches the provided `cid` +func (r *FSDUserRecord) LoadByCID(db *sql.DB, cid int) error { + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + var stmt *sql.Stmt + var err error + // Prepare the statement + if stmt, err = db.PrepareContext(ctx, "SELECT * FROM users WHERE cid=? LIMIT 1"); err != nil { + return err + } + + var record FSDUserRecord + if err = stmt.QueryRowContext(ctx, cid).Scan(&record.CID, &record.Email, &record.FirstName, + &record.LastName, &record.Password, &record.FSDPassword, &record.NetworkRating, + &record.PilotRating, &record.CreatedAt, &record.UpdatedAt); err != nil { + return err + } + + if err = stmt.Close(); err != nil { + return err + } + + // Copy record into receiver + *r = record + + return nil +} + +// Insert inserts this user record into the database `db` +// Automatically hashes provided passwords. +// Received FSDUserRecord should contain the plaintext passwords to hash. +// Returns the automatically assigned CID +func (r *FSDUserRecord) Insert(db *sql.DB) (cid int, err error) { + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + // hash the passwords + var passwordHash []byte + if passwordHash, err = bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.MinCost); err != nil { + return -1, err + } + + var fsdPasswordHash []byte + if fsdPasswordHash, err = bcrypt.GenerateFromPassword([]byte(r.FSDPassword), bcrypt.MinCost); err != nil { + return -1, err + } + + var stmt *sql.Stmt + // Prepare the statement + if stmt, err = db.PrepareContext(ctx, "INSERT INTO users (email, first_name, last_name, password, fsd_password, network_rating, pilot_rating) VALUES (?, ?, ?, ?, ?, ?, ?)"); err != nil { + return -1, err + } + + var res sql.Result + if res, err = stmt.ExecContext(ctx, r.Email, r.FirstName, r.LastName, string(passwordHash), string(fsdPasswordHash), int(r.NetworkRating), int(r.PilotRating)); err != nil { + return -1, err + } + + if err = stmt.Close(); err != nil { + return -1, err + } + + var cid64 int64 + if cid64, err = res.LastInsertId(); err != nil { + return -1, err + } + + return int(cid64), nil +} + +// Delete deletes this user record from the database `db` +// Returns NoRowsChangedError when nothing is deleted +func (r *FSDUserRecord) Delete(db *sql.DB, cid int) (err error) { + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCtx() + + var stmt *sql.Stmt + // Prepare the statement + if stmt, err = db.PrepareContext(ctx, "DELETE FROM users WHERE cid=?"); err != nil { + return err + } + + var res sql.Result + if res, err = stmt.ExecContext(ctx, cid); err != nil { + return err + } + + if err = stmt.Close(); err != nil { + return err + } + + if rows, err := res.RowsAffected(); err != nil { + return err + } else if rows != 1 { + return NoRowsChangedError + } + + return nil +} diff --git a/database_util.go b/database_util.go deleted file mode 100644 index 120b6ac..0000000 --- a/database_util.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "golang.org/x/crypto/bcrypt" - "time" -) - -type FSDUserRecord struct { - CID int `json:"cid"` - Password string `json:"password,omitempty"` - Rating int `json:"rating"` - RealName string `json:"real_name"` - CreationTime time.Time `json:"creation_time"` -} - -// GetUserRecord returns a user record for a given CID -// If no user is found, this is not an error. FSDUserRecord will be nil, and error will be nil. -func GetUserRecord(db *sql.DB, cid int) (*FSDUserRecord, error) { - row := db.QueryRow("SELECT * FROM users WHERE cid=? LIMIT 1", cid) - - var cidRecord int - var pwd string - var rating int - var realName string - var creationTime time.Time - - err := row.Scan(&cidRecord, &pwd, &rating, &realName, &creationTime) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - - return &FSDUserRecord{ - CID: cidRecord, - Password: pwd, - Rating: rating, - RealName: realName, - CreationTime: creationTime, - }, nil -} - -func AddUserRecord(db *sql.DB, cid int, pwdHash string, rating int, realName string) (*FSDUserRecord, error) { - t := time.Now() - - res, err := db.Exec("INSERT INTO users (cid, password, rating, real_name, creation_time) VALUES(?,?,?,?,?);", cid, pwdHash, rating, realName, t) - if err != nil { - return nil, err - } - - resCID, err := res.LastInsertId() - if err != nil { - return nil, err - } - - record := FSDUserRecord{ - CID: int(resCID), - Password: "", - Rating: rating, - RealName: realName, - CreationTime: t, - } - - return &record, nil -} - -func AddUserRecordSequential(db *sql.DB, pwdHash string, rating int, realName string) (*FSDUserRecord, error) { - t := time.Now() - - res, err := db.Exec("INSERT INTO users (password, rating, real_name, creation_time) VALUES(?,?,?,?);", pwdHash, rating, realName, t) - if err != nil { - return nil, err - } - - resCID, err := res.LastInsertId() - if err != nil { - return nil, err - } - - record := FSDUserRecord{ - CID: int(resCID), - Password: "", - Rating: rating, - RealName: realName, - CreationTime: t, - } - - return &record, nil -} - -// UpdateUserRecord updates a user record. It only updates the password field if it is non-empty. -func UpdateUserRecord(db *sql.DB, record *FSDUserRecord) error { - var pwdHash []byte - var err error - if record.Password != "" { - pwdHash, err = bcrypt.GenerateFromPassword([]byte(record.Password), 10) - if err != nil { - return err - } - } - - if pwdHash == nil { - _, err := db.Exec("UPDATE users SET rating=?, real_name=? WHERE cid=?;", record.Rating, record.RealName, record.CID) - if err != nil { - return err - } - } else { - _, err := db.Exec("UPDATE users SET password=?, rating=?, real_name=? WHERE cid=?;", string(pwdHash), record.Rating, record.RealName, record.CID) - if err != nil { - return err - } - } - - return nil -} - -func IsUsersTableEmpty(db *sql.DB) (bool, error) { - row := db.QueryRow("SELECT * FROM users LIMIT 1;") - - var cid int - var pwd string - var rating int - var realName string - var creationTime time.Time - - if err := row.Scan(&cid, &pwd, &rating, &realName, &creationTime); errors.Is(err, sql.ErrNoRows) { - return true, nil - } else { - return false, err - } -} diff --git a/datafeed/datafeed.go b/datafeed/datafeed.go new file mode 100644 index 0000000..69b828c --- /dev/null +++ b/datafeed/datafeed.go @@ -0,0 +1,28 @@ +package datafeed + +import ( + "sync" + "time" +) + +type DataFeed struct { + lock sync.RWMutex + currentFeed string + lastUpdated time.Time +} + +func (d *DataFeed) Feed() (feed string, lastUpdated time.Time) { + d.lock.RLock() + feed = d.currentFeed + lastUpdated = d.lastUpdated + d.lock.RUnlock() + + return +} + +func (d *DataFeed) SetFeed(feed string, lastUpdated time.Time) { + d.lock.Lock() + d.currentFeed = feed + d.lastUpdated = lastUpdated + d.lock.Unlock() +} diff --git a/docker-compose.yml b/docker-compose.yml index 7e7bce0..1d942b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,42 @@ services: - fsd: - build: - context: "." + db: + image: docker.io/mysql:latest + restart: unless-stopped + expose: + - "3306:3306/tcp" + networks: + - openfsd-net + environment: + MYSQL_ROOT_PASSWORD: supersecretpassword + MYSQL_DATABASE: openfsd + MYSQL_ROOT_HOST: '%' + healthcheck: + test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD + start_period: 5s + interval: 5s + timeout: 15s + retries: 8 + + openfsd: + image: docker.io/renorris/openfsd:latest + restart: unless-stopped + depends_on: + db: + condition: service_healthy ports: - "6809:6809/tcp" - - "9086:9086/tcp" - volumes: - - ./db:/openfsd/db + - "8080:8080/tcp" + networks: + - openfsd-net environment: - FSD_ADDR: "0.0.0.0:6809" - HTTP_ADDR: "0.0.0.0:9086" - HTTPS_ENABLED: false - DATABASE_FILE: "./db/fsd.db" - MOTD: "openfsd" - restart: unless-stopped + GOMAXPROCS: 1 + PLAINTEXT_PASSWORDS: true + MYSQL_USER: root + MYSQL_PASS: supersecretpassword + MYSQL_NET: tcp + MYSQL_ADDR: db:3306 + MYSQL_DBNAME: openfsd + +networks: + openfsd-net: + driver: bridge \ No newline at end of file diff --git a/fsd_client.go b/fsd_client.go deleted file mode 100644 index e90bbee..0000000 --- a/fsd_client.go +++ /dev/null @@ -1,460 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "errors" - "github.com/golang-jwt/jwt/v5" - "github.com/renorris/openfsd/protocol" - "github.com/renorris/openfsd/vatsimauth" - "golang.org/x/crypto/bcrypt" - "io" - "log" - "net" - "strconv" - "strings" - "time" -) - -type FSDClient struct { - Ctx context.Context - CancelCtx func() - - Conn *net.TCPConn - Reader *bufio.Reader - - AuthVerify *vatsimauth.VatsimAuth // Auth state to verify client's auth responses - PendingAuthVerifyChallenge string // Store the pending challenge sent to the client - AuthSelf *vatsimauth.VatsimAuth // Auth state for interrogating client - - Callsign string - CID int - NetworkRating int - SimulatorType int - RealName string - CurrentGeohash uint64 - SendFastEnabled bool - - Kill chan string // Signal to disconnect this client - Mailbox chan string // Incoming messages -} - -// EventLoop runs the main event loop for an FSD client. -// All clients that reach this stage are logged in -func EventLoop(client *FSDClient) { - - // Setup reader goroutine - incomingPackets := make(chan string) - go func(ctx context.Context, packetChan chan string) { - defer close(packetChan) - - for { - // Reset the deadline - err := client.Conn.SetReadDeadline(time.Now().Add(10 * time.Second)) - if err != nil { - return - } - - var buf []byte - buf, err = client.Reader.ReadSlice('\n') - if err != nil { - return - } - - packet := string(buf) - - // Validate delimiter - if len(packet) < 2 || string(packet[len(packet)-2:]) != "\r\n" { - return - } - - // Send the packet over the channel - // (also watch for context.Done) - select { - case <-ctx.Done(): - return - case packetChan <- packet: - } - } - }(client.Ctx, incomingPackets) - - // Defer "delete pilot" broadcast - defer func(client *FSDClient) { - deletePilotPDU := protocol.DeletePilotPDU{ - From: client.Callsign, - CID: client.CID, - } - - mail := NewMail(client) - mail.SetType(MailTypeBroadcastAll) - mail.AddPacket(deletePilotPDU.Serialize()) - PO.SendMail([]Mail{*mail}) - }(client) - - // Main loop - for { - select { - case <-client.Ctx.Done(): // Check for context cancel - return - - case packet, ok := <-incomingPackets: // Read incoming packets - // Check if the reader closed - if !ok { - return - } - - // Find the processor for this packet - processor, err := GetProcessor(packet) - if err != nil { - sendSyntaxError(client.Conn) - return - } - - result := processor(client, packet) - - // Send replies to the client - for _, replyPacket := range result.Replies { - err := client.writePacket(5*time.Second, replyPacket) - if err != nil { - return - } - } - - // Send mail - PO.SendMail(result.Mail) - - // Disconnect the client if flagged - if result.ShouldDisconnect { - return - } - - case mailPacket := <-client.Mailbox: // Read incoming mail messages - err := client.writePacket(5*time.Second, mailPacket) - if err != nil { - return - } - - case killPacket, ok := <-client.Kill: // Read incoming kill signals - if !ok { - return - } - - // Write the kill packet - err := client.writePacket(5*time.Second, killPacket) - if err != nil { - return - } - - // Close connection - return - } - } -} - -func sendSyntaxError(conn *net.TCPConn) { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.SyntaxError).Serialize())) -} - -func HandleConnection(conn *net.TCPConn) { - // Set the linger value to 1 second - err := conn.SetLinger(1) - if err != nil { - log.Printf("error setting connection linger value") - return - } - - // Defer connection close - defer func(conn *net.TCPConn) { - err := conn.Close() - if err != nil { - log.Println("error closing connection: " + err.Error()) - } - }(conn) - - // Generate the initial challenge - initChallenge, err := vatsimauth.GenerateChallenge() - if err != nil { - log.Printf("Error generating challenge string:\n%s", err.Error()) - return - } - - serverIdentPDU := protocol.ServerIdentificationPDU{ - From: protocol.ServerCallsign, - To: "CLIENT", - Version: "openfsd", - InitialChallenge: initChallenge, - } - serverIdentPacket := serverIdentPDU.Serialize() - - // The client has 5 seconds to log in - if err = conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - return - } - if err = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { - return - } - - _, err = io.Copy(conn, bytes.NewReader([]byte(serverIdentPacket))) - if err != nil { - return - } - - reader := bufio.NewReaderSize(conn, 1024) - var buf []byte - - buf, err = reader.ReadSlice('\n') - if err != nil { - sendSyntaxError(conn) - return - } - packet := string(buf) - - // Validate delimiter - if len(packet) < 2 || string(packet[len(packet)-2:]) != "\r\n" { - sendSyntaxError(conn) - return - } - - clientIdentPDU, err := protocol.ParseClientIdentificationPDU(packet) - if err != nil { - var fsdError *protocol.FSDError - if errors.As(err, &fsdError) { - conn.Write([]byte(fsdError.Serialize())) - } - return - } - - buf, err = reader.ReadSlice('\n') - if err != nil { - sendSyntaxError(conn) - return - } - packet = string(buf) - - // Validate delimiter - if len(packet) < 2 || string(packet[len(packet)-2:]) != "\r\n" { - sendSyntaxError(conn) - return - } - - addPilotPDU, err := protocol.ParseAddPilotPDU(packet) - if err != nil { - var fsdError *protocol.FSDError - if errors.As(err, &fsdError) { - conn.Write([]byte(fsdError.Serialize())) - } - return - } - - // Handle authentication - var networkRating int - if SC.PlaintextPasswords { // Treat token field as a plaintext password - plaintextPassword := addPilotPDU.Token - networkRating = 0 - - userRecord, err := GetUserRecord(DB, clientIdentPDU.CID) - if err != nil { // Check for error - log.Printf("error fetching user record: " + err.Error()) - return - } - - if userRecord == nil { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.InvalidLogonError).Serialize())) - return - } - - if userRecord.Rating < addPilotPDU.NetworkRating { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError).Serialize())) - return - } - - err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(plaintextPassword)) - if err != nil { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.InvalidLogonError).Serialize())) - return - } - } else { // Treat token field as a JWT token - networkRating, err = verifyJWTToken(clientIdentPDU.CID, addPilotPDU.NetworkRating, addPilotPDU.Token) - if err != nil { - var fsdError *protocol.FSDError - if errors.As(err, &fsdError) { - conn.Write([]byte(fsdError.Serialize())) - } else { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.InvalidLogonError).Serialize())) - } - return - } - } - - // Verify callsign - switch clientIdentPDU.From { - case protocol.ServerCallsign, protocol.ClientQueryBroadcastRecipient, protocol.ClientQueryBroadcastRecipientPilots: - conn.Write([]byte(protocol.NewGenericFSDError(protocol.CallsignInvalidError).Serialize())) - return - } - - // Verify protocol revision - if addPilotPDU.ProtocolRevision != protocol.ProtoRevisionVatsim2022 { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError).Serialize())) - return - } - - // Verify if we support this client - _, ok := vatsimauth.Keys[clientIdentPDU.ClientID] - if !ok { - conn.Write([]byte(protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError).Serialize())) - return - } - - // Verify fields in login PDUs - if clientIdentPDU.From != addPilotPDU.From || - clientIdentPDU.CID != addPilotPDU.CID { - sendSyntaxError(conn) - return - } - - // Configure client - ctx, cancelCtx := context.WithCancel(context.Background()) - - // Defer context close - defer func(cancelCtx func()) { - cancelCtx() - }(cancelCtx) - - fsdClient := FSDClient{ - Ctx: ctx, - CancelCtx: cancelCtx, - Conn: conn, - Reader: reader, - AuthVerify: &vatsimauth.VatsimAuth{}, - PendingAuthVerifyChallenge: "", - AuthSelf: &vatsimauth.VatsimAuth{}, - Callsign: clientIdentPDU.From, - CID: clientIdentPDU.CID, - NetworkRating: networkRating, - SimulatorType: addPilotPDU.SimulatorType, - RealName: addPilotPDU.RealName, - CurrentGeohash: 0, - SendFastEnabled: false, - Kill: make(chan string, 1), - Mailbox: make(chan string, 16), - } - - // Register callsign to the post office. End the connection if callsign already exists - err = PO.RegisterCallsign(clientIdentPDU.From, &fsdClient) - if err != nil { - if errors.Is(err, CallsignAlreadyRegisteredError) { - pdu := protocol.NewGenericFSDError(protocol.CallsignInUseError) - conn.Write([]byte(pdu.Serialize())) - } - return - } - - // Defer deregistration - defer func(callsign string) { - err := PO.DeregisterCallsign(callsign) - if err != nil { - log.Printf("error deregistering callsign: " + err.Error()) - } - }(clientIdentPDU.From) - - // Configure vatsim auth states - fsdClient.AuthSelf = vatsimauth.NewVatsimAuth(clientIdentPDU.ClientID, vatsimauth.Keys[clientIdentPDU.ClientID]) - fsdClient.AuthSelf.SetInitialChallenge(clientIdentPDU.InitialChallenge) - fsdClient.AuthVerify = vatsimauth.NewVatsimAuth(clientIdentPDU.ClientID, vatsimauth.Keys[clientIdentPDU.ClientID]) - fsdClient.AuthVerify.SetInitialChallenge(initChallenge) - - // Broadcast AddPilot packet to network - addPilotPDU.Token = "" - mail := NewMail(&fsdClient) - mail.SetType(MailTypeBroadcastAll) - mail.AddPacket(addPilotPDU.Serialize()) - PO.SendMail([]Mail{*mail}) - - // Send MOTD - lines := strings.Split(SC.MOTD, "\n") - for _, line := range lines { - pdu := protocol.TextMessagePDU{ - From: protocol.ServerCallsign, - To: clientIdentPDU.From, - Message: line, - } - _, err := conn.Write([]byte(pdu.Serialize())) - if err != nil { - return - } - } - - // Start the event loop - EventLoop(&fsdClient) -} - -// writePacket writes a packet to this client's connection -// timeout sets the write deadline (relative to time.Now). No deadline will be set if timeout = -1 -func (c *FSDClient) writePacket(timeout time.Duration, packet string) error { - // Reset the deadline - if timeout > 0 { - err := c.Conn.SetWriteDeadline(time.Now().Add(timeout * time.Second)) - if err != nil { - return err - } - } - - // Attempt to write the packet - _, err := io.Copy(c.Conn, bytes.NewReader([]byte(packet))) - if err != nil { - return err - } - - return nil -} - -// verifyJWTToken compares the claimed fields token `token` to cid and networkRating (from the plaintext FSD packet) -// Returns the signed network rating on success -func verifyJWTToken(cid, networkRating int, token string) (signedNetworkRating int, err error) { - // Validate token signature - claims := jwt.MapClaims{} - t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { - return JWTKey, nil - }) - if err != nil { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - - // Check for expiry - exp, err := t.Claims.GetExpirationTime() - if err != nil { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - if time.Now().After(exp.Time) { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - - // Verify claimed CID - claimedCID, err := claims.GetSubject() - if err != nil { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - - cidInt, err := strconv.Atoi(claimedCID) - if err != nil { - return -1, errors.Join(errors.New("error parsing CID")) - } - - if cidInt != cid { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - - // Verify controller rating - claimedRating, ok := claims["controller_rating"].(float64) - if !ok { - return -1, protocol.NewGenericFSDError(protocol.InvalidLogonError) - } - - if networkRating > int(claimedRating) { - return -1, protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError) - } - - return int(claimedRating), nil -} diff --git a/fsd_jwt_auth_test.go b/fsd_jwt_auth_test.go deleted file mode 100644 index 99aa2ff..0000000 --- a/fsd_jwt_auth_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/assert" - "net/http" - "os" - "testing" - "time" -) - -func doJwtRequest(t *testing.T, url string, cid int, password string) *JwtResponse { - jwtRequest := JwtRequest{ - CID: fmt.Sprintf("%d", cid), - Password: password, - } - jsonData, err := json.Marshal(jwtRequest) - assert.Nil(t, err) - - client := http.Client{} - resp, err := client.Post(url, "application/json", bytes.NewReader(jsonData)) - assert.Nil(t, err) - - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(resp.Body) - assert.Nil(t, err) - var jwtResponse JwtResponse - err = json.Unmarshal(buf.Bytes(), &jwtResponse) - assert.Nil(t, err) - - return &jwtResponse -} - -func TestServeJwtAuthTokens(t *testing.T) { - SC = &ServerConfig{ - FsdListenAddr: "localhost:6809", - HttpListenAddr: "localhost:9086", - HttpsEnabled: false, - DatabaseFile: "./test.db", - MOTD: "", - } - os.Remove(SC.DatabaseFile) - defer os.Remove(SC.DatabaseFile) - - configureDatabase() - configureJwt() - configurePostOffice() - - addUserToDatabase(t, 1000000, "12345", 1) - - // Start http server - httpCtx, cancelHttp := context.WithCancel(context.Background()) - go StartHttpServer(httpCtx) - defer cancelHttp() - time.Sleep(50 * time.Millisecond) - - // Test successful request - { - jwtResponse := doJwtRequest(t, "http://localhost:9086/api/fsd-jwt", 1000000, "12345") - - assert.True(t, jwtResponse.Success) - assert.NotEmpty(t, jwtResponse.Token) - assert.Empty(t, jwtResponse.ErrorMsg) - - claims := jwt.MapClaims{} - token, err := jwt.ParseWithClaims(jwtResponse.Token, &claims, func(token *jwt.Token) (interface{}, error) { - return JWTKey, nil - }) - assert.Nil(t, err) - assert.NotNil(t, claims) - exp, err := token.Claims.GetExpirationTime() - assert.Nil(t, err) - iat, err := token.Claims.GetIssuedAt() - assert.Nil(t, err) - assert.True(t, exp.Sub(iat.Time) == 420*time.Second) - } - - // Test invalid CID - { - jwtResponse := doJwtRequest(t, "http://localhost:9086/api/fsd-jwt", 9999999, "12345") - - assert.False(t, jwtResponse.Success) - assert.Empty(t, jwtResponse.Token) - assert.Equal(t, jwtResponse.ErrorMsg, "User not found") - } - - // Test invalid password - { - jwtResponse := doJwtRequest(t, "http://localhost:9086/api/fsd-jwt", 1000000, "54321") - - assert.False(t, jwtResponse.Success) - assert.Empty(t, jwtResponse.Token) - assert.Equal(t, jwtResponse.ErrorMsg, "Password is incorrect") - } -} diff --git a/go.mod b/go.mod index a46b4ac..45676e8 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,55 @@ module github.com/renorris/openfsd -go 1.23 +go 1.23.2 require ( - github.com/go-playground/validator/v10 v10.22.0 + github.com/dolthub/go-mysql-server v0.18.1 + github.com/fatih/color v1.17.0 + github.com/go-playground/validator/v10 v10.22.1 + github.com/go-sql-driver/mysql v1.8.1 github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/mattn/go-sqlite3 v1.14.22 github.com/mmcloughlin/geohash v0.10.0 github.com/sethvargo/go-envconfig v1.1.0 github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.25.0 + golang.org/x/crypto v0.27.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect + github.com/dolthub/go-icu-regex v0.0.0-20240916130659-0118adc6b662 // indirect + github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect + github.com/dolthub/vitess v0.0.0-20240404214255-c5a87fc7b325 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect + github.com/go-kit/kit v0.10.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/strftime v1.0.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + github.com/tetratelabs/wazero v1.1.0 // indirect + go.opentelemetry.io/otel v1.7.0 // indirect + go.opentelemetry.io/otel/trace v1.7.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/tools v0.25.0 // indirect + google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect + google.golang.org/grpc v1.53.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a7d7277..d0b9080 100644 --- a/go.sum +++ b/go.sum @@ -1,50 +1,465 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 h1:u3PMzfF8RkKd3lB9pZ2bfn0qEG+1Gms9599cr0REMww= +github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2/go.mod h1:mIEZOHnFx4ZMQeawhw9rhsj+0zwQj7adVsnBX7t+eKY= +github.com/dolthub/go-icu-regex v0.0.0-20240916130659-0118adc6b662 h1:aC17hZD6iwzBwwfO5M+3oBT5E5gGRiQPdn+vzpDXqIA= +github.com/dolthub/go-icu-regex v0.0.0-20240916130659-0118adc6b662/go.mod h1:KPUcpx070QOfJK1gNe0zx4pA5sicIK1GMikIGLKC168= +github.com/dolthub/go-mysql-server v0.18.1 h1:T+mTBfLrZPnOKvVx3iRx66f0oW+0saOnPa+O1OKUklQ= +github.com/dolthub/go-mysql-server v0.18.1/go.mod h1:8zjK76NDWRel1CFdg+DDzy/D5tdOeFOYKBcqf7IB+aA= +github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 h1:bMGS25NWAGTEtT5tOBsCuCrlYnLRKpbJVJkDbrTRhwQ= +github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71/go.mod h1:2/2zjLQ/JOOSbbSboojeg+cAwcRV0fDLzIiWch/lhqI= +github.com/dolthub/vitess v0.0.0-20240404214255-c5a87fc7b325 h1:MYUzL2faXlBlG+EEBf+55e5RE/9k8O39MvPXGRAhjJQ= +github.com/dolthub/vitess v0.0.0-20240404214255-c5a87fc7b325/go.mod h1:Xy89nzEyIwlMCiFWOJPmlnORpDFz5wFgEdYGfUwbIQ0= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= +github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9BHElA8= +github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mmcloughlin/geohash v0.10.0 h1:9w1HchfDfdeLc+jFEf/04D27KP7E2QmpDu52wPbJWRE= github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= github.com/sethvargo/go-envconfig v1.1.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/tetratelabs/wazero v1.1.0 h1:EByoAhC+QcYpwSZJSs/aV0uokxPwBgKxfiokSUwAknQ= +github.com/tetratelabs/wazero v1.1.0/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/otel v1.7.0 h1:Z2lA3Tdch0iDcrhJXDIlC94XE+bxok1F9B+4Lz/lGsM= +go.opentelemetry.io/otel v1.7.0/go.mod h1:5BdUoMIz5WEs0vt0CUEMtSSaTSHBBVwrhnz7+nrD5xk= +go.opentelemetry.io/otel/trace v1.7.0 h1:O37Iogk1lEkMRXewVtZ1BBTVn5JEp8GrJvP92bJqC6o= +go.opentelemetry.io/otel/trace v1.7.0/go.mod h1:fzLSB9nqR2eXzxPXb2JW9IKE+ScyXA48yyE4TNvoHqU= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= +google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/src-d/go-errors.v1 v1.0.0 h1:cooGdZnCjYbeS1zb1s6pVAAimTdKceRrpn7aKOnNIfc= +gopkg.in/src-d/go-errors.v1 v1.0.0/go.mod h1:q1cBlomlw2FnDBDNGlnh6X0jPihy+QxZfMMNxPCbdYg= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/handler/auth_challenge.go b/handler/auth_challenge.go new file mode 100644 index 0000000..a7546eb --- /dev/null +++ b/handler/auth_challenge.go @@ -0,0 +1,49 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/protocol/vatsimauth" +) + +func authChallengeHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.AuthChallengePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Generate response + challengeResponse := invoker.AuthSelf().GenerateResponse(pdu.Challenge) + invoker.AuthSelf().UpdateState(challengeResponse) + + challengeResponsePDU := protocol.AuthChallengeResponsePDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + ChallengeResponse: challengeResponse, + } + + // Send a counter-challenge + var chal string + if chal, err = vatsimauth.GenerateChallenge(); err != nil { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error: error generating counter-challenge string") + return + } + + invoker.SetPendingChallenge(chal) + + newChallengePDU := protocol.AuthChallengePDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + Challenge: invoker.PendingChallenge(), + } + + result.addReply(challengeResponsePDU.Serialize()) + result.addReply(newChallengePDU.Serialize()) + + return +} diff --git a/handler/auth_challenge_response.go b/handler/auth_challenge_response.go new file mode 100644 index 0000000..b228ac0 --- /dev/null +++ b/handler/auth_challenge_response.go @@ -0,0 +1,30 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" +) + +func authChallengeResponseHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.AuthChallengeResponsePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Verify the response with the stored pending challenge + var res string + if res = invoker.AuthVerify().GenerateResponse(invoker.PendingChallenge()); res != pdu.ChallengeResponse { + result.setDisconnectFlag() + err = protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError, pdu.ChallengeResponse, "incorrect challenge response") + return + } + + invoker.AuthVerify().UpdateState(res) + + return +} diff --git a/handler/broadcast_message.go b/handler/broadcast_message.go new file mode 100644 index 0000000..a4887e7 --- /dev/null +++ b/handler/broadcast_message.go @@ -0,0 +1,36 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func broadcastMessageHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.TextMessagePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Ignore if the user doesn't have permission + if invoker.NetworkRating() < protocol.NetworkRatingSUP { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "insufficient permission to broadcast") + return + } + + // Verify the To field is set to `*` + if pdu.To != "*" { + err = protocol.NewGenericFSDError(protocol.SyntaxError, pdu.To, "broadcast message recipient must be '*'") + return + } + + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeBroadcast, "", packet) + result.addMail(mail) + + return +} diff --git a/handler/client_query.go b/handler/client_query.go new file mode 100644 index 0000000..8b7b4aa --- /dev/null +++ b/handler/client_query.go @@ -0,0 +1,50 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "strings" +) + +func clientQueryHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.ClientQueryPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + switch pdu.To { + case protocol.ServerCallsign: + // Only handle IP queries + if pdu.QueryType != protocol.ClientQueryPublicIP { + return + } + + ip := strings.Split(invoker.RemoteNetworkAddrString(), ":")[0] + responsePDU := protocol.ClientQueryResponsePDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + QueryType: protocol.ClientQueryPublicIP, + Payload: ip, + } + result.addReply(responsePDU.Serialize()) + return + + case protocol.ClientQueryBroadcastRecipient, protocol.ClientQueryBroadcastRecipientPilots: + // Proximity broadcast + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + return + } + + // Assume direct message client query + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/client_query_response.go b/handler/client_query_response.go new file mode 100644 index 0000000..5a9dd00 --- /dev/null +++ b/handler/client_query_response.go @@ -0,0 +1,29 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func clientQueryResponseHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.ClientQueryResponsePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Ignore responses to server callsign + if pdu.To == protocol.ServerCallsign { + return + } + + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/delete_pilot.go b/handler/delete_pilot.go new file mode 100644 index 0000000..9efc917 --- /dev/null +++ b/handler/delete_pilot.go @@ -0,0 +1,35 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" + "strconv" +) + +func deletePilotHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.DeletePilotPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Check for invalid CID + if pdu.CID != invoker.CID() { + err = protocol.NewGenericFSDError(protocol.SyntaxError, strconv.Itoa(pdu.CID), "incorrect CID") + return + } + + result.addReply((&protocol.TextMessagePDU{ + From: protocol.ServerCallsign, + To: pdu.From, + Message: "Goodbye!", + }).Serialize()) + + result.setDisconnectFlag() + + return +} diff --git a/handler/direct_message.go b/handler/direct_message.go new file mode 100644 index 0000000..2850c61 --- /dev/null +++ b/handler/direct_message.go @@ -0,0 +1,24 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func directMessageHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.TextMessagePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/fast_pilot_position.go b/handler/fast_pilot_position.go new file mode 100644 index 0000000..aa9ba2c --- /dev/null +++ b/handler/fast_pilot_position.go @@ -0,0 +1,32 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" +) + +func fastPilotPositionHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.FastPilotPositionPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Update location for post office if slow/stopped type + switch pdu.Type { + case protocol.FastPilotPositionTypeSlow, protocol.FastPilotPositionTypeStopped: + invoker.SetGeohash(servercontext.PostOffice().SetLocation(invoker.Address(), pdu.Lat, pdu.Lng)) + } + + // Proximity broadcast position update + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeCloseProximityBroadcast, "", packet) + result.addMail(mail) + + return +} diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..79f3f2a --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,99 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" + "strings" +) + +// Handler represents a function to process an FSD packet +type Handler func(invoker Invoker, packet string) (Result, error) + +// New parses the provided packet's identifier and returns a handler function +func New(packet string) (handler Handler, err error) { + // Trim packet delimiter and verify length + if packet = strings.TrimSuffix(packet, protocol.PacketDelimiter); len(packet) < 3 { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "invalid packet: too short") + return + } + + fields := strings.Split(packet, protocol.Delimiter) + + switch packet[0] { + case '^': + handler = fastPilotPositionHandler + case '@': + handler = pilotPositionHandler + case '$': + pduID := packet[0:3] + switch pduID { + case "$CQ": + handler = clientQueryHandler + case "$CR": + handler = clientQueryResponseHandler + case "$ZC": + handler = authChallengeHandler + case "$ZR": + handler = authChallengeResponseHandler + case "$!!": + handler = killRequestHandler + case "$PI": + handler = pingHandler + case "$AX": + // TODO: implement METAR request + } + case '#': + pduID := packet[0:3] + switch pduID { + case "#SL": + handler = fastPilotPositionHandler + case "#ST": + handler = fastPilotPositionHandler + case "#DP": + handler = deletePilotHandler + case "#SB": + if len(fields) < 3 { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "unrecognized #SB packet: invalid parameter count") + return + } + switch fields[2] { + case "PIR": + handler = planeInfoRequestHandler + case "FSIPIR": + handler = planeInfoRequestFsinnHandler + case "PI": + if len(fields) > 3 && fields[3] == "GEN" { + handler = planeInfoResponseHandler + } + } + case "#TM": + if len(fields) < 3 { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "unrecognized #TM packet: invalid parameter count") + return + } + switch fields[1] { + case "*": + handler = broadcastMessageHandler + case "*S": + handler = wallopMessageHandler + default: + if len(fields[1]) > 0 && fields[1][0] == '@' { + handler = radioMessageHandler + } else { + handler = directMessageHandler + } + } + } + } + + if handler == nil { + err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "unrecognized packet type") + return + } + + return +} + +func pduSourceInvalidResult() (result Result, err error) { + err = protocol.NewGenericFSDError(protocol.PDUSourceInvalidError, "", "") + return +} diff --git a/handler/invoker.go b/handler/invoker.go new file mode 100644 index 0000000..d50f105 --- /dev/null +++ b/handler/invoker.go @@ -0,0 +1,30 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/protocol/vatsimauth" +) + +// Invoker represents an invoker of a handler function +type Invoker interface { + Callsign() string + + AuthSelf() *vatsimauth.VatsimAuth + AuthVerify() *vatsimauth.VatsimAuth + PendingChallenge() string + SetPendingChallenge(string) + + NetworkRating() protocol.NetworkRating + CID() int + SetGeohash(postoffice.Geohash) + SendFastEnabled() bool + SetSendFastEnabled(bool) + + // Address returns the Address representation of this invoker + Address() postoffice.Address + SetAddressState(*postoffice.AddressState) + + // RemoteNetworkAddrString returns the remote TCP address associated with this invoker's underlying network connection + RemoteNetworkAddrString() string +} diff --git a/handler/kill_request.go b/handler/kill_request.go new file mode 100644 index 0000000..0b4224f --- /dev/null +++ b/handler/kill_request.go @@ -0,0 +1,44 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "strconv" +) + +func killRequestHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.KillRequestPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Check if user has permission + if invoker.NetworkRating() < protocol.NetworkRatingSUP { + err = protocol.NewGenericFSDError(protocol.SyntaxError, strconv.Itoa(int(invoker.NetworkRating())), "insufficient permission to kill") + return + } + + replyPDU := protocol.TextMessagePDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + Message: "", + } + + if err = servercontext.PostOffice().Kill(&pdu); err != nil { + replyPDU.Message = err.Error() + result.addReply(replyPDU.Serialize()) + return result, nil + } + + replyPDU.Message = "killed " + pdu.To + + result.addReply(replyPDU.Serialize()) + + return +} diff --git a/handler/pilot_position.go b/handler/pilot_position.go new file mode 100644 index 0000000..a6b4579 --- /dev/null +++ b/handler/pilot_position.go @@ -0,0 +1,62 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "time" +) + +func pilotPositionHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.PilotPositionPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Write location to post office + invoker.SetGeohash(servercontext.PostOffice().SetLocation(invoker.Address(), pdu.Lat, pdu.Lng)) + + // Update SendFastEnabled state if required + if invoker.SendFastEnabled() && pdu.GroundSpeed == 0 { + invoker.SetSendFastEnabled(false) + disableSendFastPDU := protocol.SendFastPDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + DoSendFast: false, + } + result.addReply(disableSendFastPDU.Serialize()) + } else if !invoker.SendFastEnabled() && pdu.GroundSpeed > 0 { + invoker.SetSendFastEnabled(true) + enableSendFastPDU := protocol.SendFastPDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + DoSendFast: true, + } + result.addReply(enableSendFastPDU.Serialize()) + } + + // Update invoker address state + state := invoker.Address().State() + + state.Latitude = pdu.Lat + state.Longitude = pdu.Lng + state.Heading = int(pdu.Heading) + state.Groundspeed = pdu.GroundSpeed + state.Altitude = pdu.TrueAltitude + state.Transponder = pdu.SquawkCode + state.LastUpdated = time.Now() + + invoker.SetAddressState(&state) + + // Proximity broadcast position update + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeGeneralProximityBroadcast, "", packet) + result.addMail(mail) + + return +} diff --git a/handler/ping.go b/handler/ping.go new file mode 100644 index 0000000..7654315 --- /dev/null +++ b/handler/ping.go @@ -0,0 +1,32 @@ +package handler + +import ( + "github.com/renorris/openfsd/protocol" +) + +func pingHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.PingPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Ignore if the ping isn't for the server + if pdu.To != protocol.ServerCallsign { + return + } + + pongPDU := protocol.PongPDU{ + From: protocol.ServerCallsign, + To: invoker.Callsign(), + Timestamp: pdu.Timestamp, + } + result.addReply(pongPDU.Serialize()) + + return +} diff --git a/handler/plane_info_request.go b/handler/plane_info_request.go new file mode 100644 index 0000000..52805bb --- /dev/null +++ b/handler/plane_info_request.go @@ -0,0 +1,25 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func planeInfoRequestHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.PlaneInfoRequestPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Forward to recipient + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/plane_info_request_fsinn.go b/handler/plane_info_request_fsinn.go new file mode 100644 index 0000000..10185fa --- /dev/null +++ b/handler/plane_info_request_fsinn.go @@ -0,0 +1,25 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func planeInfoRequestFsinnHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.PlaneInfoRequestFsinnPDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Forward to recipient + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/plane_info_response.go b/handler/plane_info_response.go new file mode 100644 index 0000000..0b824de --- /dev/null +++ b/handler/plane_info_response.go @@ -0,0 +1,25 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func planeInfoResponseHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.PlaneInfoResponsePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Forward to recipient + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeDirect, pdu.To, packet) + result.addMail(mail) + + return +} diff --git a/handler/radio_message.go b/handler/radio_message.go new file mode 100644 index 0000000..d5129bd --- /dev/null +++ b/handler/radio_message.go @@ -0,0 +1,31 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "strings" +) + +func radioMessageHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.TextMessagePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Verify the To field is a radio frequency + if !strings.HasPrefix(pdu.To, "@") || len(pdu.To) != 6 { + err = protocol.NewGenericFSDError(protocol.SyntaxError, pdu.To, "recipient must be a radio frequency @XXXXX e.g. @22800") + return + } + + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeGeneralProximityBroadcast, "", packet) + result.addMail(mail) + + return +} diff --git a/handler/result.go b/handler/result.go new file mode 100644 index 0000000..c6893c4 --- /dev/null +++ b/handler/result.go @@ -0,0 +1,48 @@ +package handler + +import "github.com/renorris/openfsd/postoffice" + +// Result represents the result of a handler function +type Result struct { + replies []string + mail []postoffice.Mail + disconnectFlag bool +} + +// Replies returns the slice of reply packets. +// Return value may be nil. +func (r *Result) Replies() []string { + return r.replies +} + +// MailingList returns the slice of mail. +// Return value may be nil. +func (r *Result) MailingList() []postoffice.Mail { + return r.mail +} + +// DisconnectFlag returns whether the flag is set indicating to disconnect the client +func (r *Result) DisconnectFlag() bool { + return r.disconnectFlag +} + +// setDisconnectFlag sets the disconnect flag to true, which will signal the event loop to disconnect the client +func (r *Result) setDisconnectFlag() { + r.disconnectFlag = true +} + +// addReply adds a packet to be sent back to the caller client +func (r *Result) addReply(packet string) { + if r.replies == nil { + r.replies = make([]string, 0, 1) + } + r.replies = append(r.replies, packet) +} + +// AddMail adds mail to be sent to other clients +func (r *Result) addMail(mail postoffice.Mail) { + if r.mail == nil { + r.mail = make([]postoffice.Mail, 0, 1) + } + r.mail = append(r.mail, mail) +} diff --git a/handler/wallop.go b/handler/wallop.go new file mode 100644 index 0000000..a727873 --- /dev/null +++ b/handler/wallop.go @@ -0,0 +1,30 @@ +package handler + +import ( + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" +) + +func wallopMessageHandler(invoker Invoker, packet string) (result Result, err error) { + // Parse packet + pdu := protocol.TextMessagePDU{} + if err = pdu.Parse(packet); err != nil { + return + } + + // Verify source callsign + if pdu.From != invoker.Callsign() { + return pduSourceInvalidResult() + } + + // Verify the To field is set to `*S` + if pdu.To != "*S" { + err = protocol.NewGenericFSDError(protocol.SyntaxError, pdu.To, "wallop recipient must be *S") + return + } + + mail := postoffice.NewMail(invoker.Address(), postoffice.MailTypeSupervisorBroadcast, "", packet) + result.addMail(mail) + + return +} diff --git a/http_server.go b/http_server.go deleted file mode 100644 index 6d536e0..0000000 --- a/http_server.go +++ /dev/null @@ -1,511 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/rand" - _ "embed" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "github.com/golang-jwt/jwt/v5" - _ "github.com/mattn/go-sqlite3" - "github.com/renorris/openfsd/protocol" - "golang.org/x/crypto/bcrypt" - "log" - "net/http" - "strconv" - "time" -) - -//go:embed dashboard.html -var dashboardHtml []byte - -type JwtRequest struct { - CID string `json:"cid"` - Password string `json:"password"` -} - -type JwtResponse struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - ErrorMsg string `json:"error_msg,omitempty"` -} - -type CustomClaims struct { - jwt.RegisteredClaims - ControllerRating int `json:"controller_rating"` - PilotRating int `json:"pilot_rating"` -} - -type UserApiResponse struct { - Success bool `json:"success"` - Message string `json:"msg"` - User *FSDUserRecord `json:"user_record,omitempty"` -} - -func fsdJwtApiHandler(w http.ResponseWriter, r *http.Request) { - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - var jwtRequest JwtRequest - if err = json.Unmarshal(buf.Bytes(), &jwtRequest); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - cid, err := strconv.Atoi(jwtRequest.CID) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - userRecord, userRecordErr := GetUserRecord(DB, cid) - if userRecordErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - // If user not found - if userRecord == nil { - jwtResponse := JwtResponse{ - Success: false, - Token: "", - ErrorMsg: "User not found", - } - - body, marshalErr := json.Marshal(jwtResponse) - if marshalErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - _, writeErr := w.Write(body) - if writeErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - return - } - - // Verify password - userRecordErr = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(jwtRequest.Password)) - if userRecordErr != nil { // Password didn't match - jwtResponse := JwtResponse{ - Success: false, - Token: "", - ErrorMsg: "Password is incorrect", - } - - body, err := json.Marshal(jwtResponse) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - _, writeErr := w.Write(body) - if writeErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - return - } - - // Else send a login token - idBytes := make([]byte, 16) - _, userRecordErr = rand.Read(idBytes) - if userRecordErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - idStr := base64.StdEncoding.EncodeToString(idBytes) - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "openfsd", - Subject: jwtRequest.CID, - Audience: []string{"fsd-live"}, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(420 * time.Second)), - NotBefore: jwt.NewNumericDate(time.Now().Add(-120 * time.Second)), - IssuedAt: jwt.NewNumericDate(time.Now()), - ID: idStr, - }, - ControllerRating: userRecord.Rating, - PilotRating: 0, - }) - - tokenString, userRecordErr := token.SignedString(JWTKey) - if userRecordErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - jwtResponse := JwtResponse{ - Success: true, - Token: tokenString, - ErrorMsg: "", - } - - body, userRecordErr := json.Marshal(jwtResponse) - if userRecordErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - _, writeErr := w.Write(body) - if writeErr != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } -} - -func userAPIHandler(w http.ResponseWriter, r *http.Request) { - - // Check for a valid token cookie - cookie, err := r.Cookie("token") - if err != nil { - w.WriteHeader(http.StatusForbidden) - return - } - - // Validate token - claims := jwt.RegisteredClaims{} - token, err := jwt.ParseWithClaims(cookie.Value, &claims, func(token *jwt.Token) (interface{}, error) { - return JWTKey, nil - }) - - if err != nil { - w.WriteHeader(http.StatusForbidden) - return - } - - audience, err := token.Claims.GetAudience() - if err != nil { - w.WriteHeader(http.StatusForbidden) - return - } - - audienceValid := false - for _, audClaim := range audience { - if audClaim == "administrator-dashboard" { - audienceValid = true - break - } - } - - if !audienceValid { - w.WriteHeader(http.StatusForbidden) - return - } - - // Handle the request - switch r.Method { - case "GET": - cidStr := r.FormValue("cid") - - cid, err := strconv.Atoi(cidStr) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - userRecord, err := GetUserRecord(DB, cid) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - if userRecord == nil { - res := UserApiResponse{ - Success: false, - Message: "Error: user not found", - } - - resBytes, err := json.Marshal(res) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(resBytes) - return - } - - // omit password - userRecord.Password = "" - - res := UserApiResponse{ - Success: true, - Message: "Success", - User: userRecord, - } - - resBytes, err := json.Marshal(res) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(resBytes) - return - case "POST": - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - req := FSDUserRecord{} - err = json.Unmarshal(buf.Bytes(), &req) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - bcryptBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) - passwordHash := string(bcryptBytes) - - record, err := AddUserRecordSequential(DB, passwordHash, req.Rating, req.RealName) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - record.Password = "" - - res := UserApiResponse{ - Success: true, - Message: "Success: added user with CID " + fmt.Sprintf("%d", record.CID), - User: record, - } - - resBytes, err := json.Marshal(res) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(resBytes) - return - case "PATCH": - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - req := FSDUserRecord{} - err = json.Unmarshal(buf.Bytes(), &req) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - err = UpdateUserRecord(DB, &req) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - req.Password = "" - - res := UserApiResponse{ - Success: true, - Message: "Success: updated user with CID " + fmt.Sprintf("%d", req.CID), - User: &req, - } - - resBytes, err := json.Marshal(res) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(resBytes) - return - } -} - -func dashboardHandler(w http.ResponseWriter, r *http.Request) { - - // If we have a valid cookie containing a valid JWT, send the dashboard page - cookie, err := r.Cookie("token") - if err == nil { - claims := jwt.RegisteredClaims{} - token, err := jwt.ParseWithClaims(cookie.Value, &claims, func(token *jwt.Token) (interface{}, error) { - return JWTKey, nil - }) - - // If the token is invalid, remove the cookie and send back basic auth - if err != nil { - cookie.Expires = time.Unix(0, 0) - cookie.Value = "" - http.SetCookie(w, cookie) - - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - audience, err := token.Claims.GetAudience() - if err != nil { - w.WriteHeader(http.StatusForbidden) - return - } - - audienceValid := false - for _, audClaim := range audience { - if audClaim == "administrator-dashboard" { - audienceValid = true - break - } - } - - if !audienceValid { - w.WriteHeader(http.StatusForbidden) - return - } - - w.WriteHeader(http.StatusOK) - w.Write(dashboardHtml) - return - } - - // If we don't have a cookie, check if we were sent basic auth information - username, pwd, ok := r.BasicAuth() - - // If not, send the basic auth header - if !ok { - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - cid, err := strconv.Atoi(username) - if err != nil { - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - - userRecord, err := GetUserRecord(DB, cid) - if err != nil { - w.Header().Add("Content-Type", "text/plain") - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal server error")) - return - } - - if userRecord == nil { - w.Header().Add("Content-Type", "text/plain") - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("user not found")) - return - } - - if userRecord.Rating < protocol.NetworkRatingSUP { - w.Header().Add("Content-Type", "text/plain") - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("rating too low")) - return - } - - err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(pwd)) - if err != nil { - w.Header().Add("Content-Type", "text/plain") - w.Header().Add("WWW-Authenticate", `Basic realm="dashboard", charset="UTF-8"`) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("user not found")) - return - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ - Issuer: "openfsd", - Subject: fmt.Sprintf("%d", userRecord.CID), - Audience: []string{"administrator-dashboard"}, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(12 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }) - - tokenStr, err := token.SignedString(JWTKey) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "token", - Value: tokenStr, - Expires: time.Now().Add(12 * time.Hour), - }) - - w.WriteHeader(http.StatusOK) - w.Write(dashboardHtml) -} - -func defaultHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) -} - -func StartHttpServer(ctx context.Context) { - mux := http.NewServeMux() - mux.HandleFunc("POST /api/fsd-jwt", fsdJwtApiHandler) - mux.HandleFunc("GET /dashboard", dashboardHandler) - mux.HandleFunc("/user", userAPIHandler) - mux.HandleFunc("/", defaultHandler) - server := &http.Server{Addr: SC.HttpListenAddr, Handler: mux} - go func() { - if SC.HttpsEnabled { - if err := server.ListenAndServeTLS(SC.TLSCertFile, SC.TLSKeyFile); err != nil { - if !errors.Is(err, http.ErrServerClosed) { - log.Fatal("https server error:\n" + err.Error()) - } - } - } else { - if err := server.ListenAndServe(); err != nil { - if !errors.Is(err, http.ErrServerClosed) { - log.Fatal("http server error:\n" + err.Error()) - } - } - } - - }() - - log.Println("HTTP listening") - - // Wait for context done signal - <-ctx.Done() - - // Shutdown server - if err := server.Shutdown(context.Background()); err != nil { - log.Fatal("http server shutdown error:\n" + err.Error()) - } -} diff --git a/main.go b/main.go index 936007c..9814a27 100644 --- a/main.go +++ b/main.go @@ -2,211 +2,39 @@ package main import ( "context" - "crypto/rand" - "database/sql" - "encoding/hex" - "fmt" - "github.com/go-playground/validator/v10" - _ "github.com/mattn/go-sqlite3" - "github.com/renorris/openfsd/protocol" - "github.com/sethvargo/go-envconfig" - "golang.org/x/crypto/bcrypt" - "io" + "github.com/renorris/openfsd/bootstrap" "log" - "net" "os" "os/signal" "syscall" ) -type ServerConfig struct { - FsdListenAddr string `env:"FSD_ADDR, default=0.0.0.0:6809"` - HttpListenAddr string `env:"HTTP_ADDR, default=0.0.0.0:9086"` - HttpsEnabled bool `env:"HTTPS_ENABLED, default=false"` - TLSCertFile string `env:"TLS_CERT_FILE"` - TLSKeyFile string `env:"TLS_KEY_FILE"` - DatabaseFile string `env:"DATABASE_FILE, default=./db/fsd.db"` - MOTD string `env:"MOTD, default=openfsd"` - PlaintextPasswords bool `env:"PLAINTEXT_PASSWORDS, default=false"` -} - -var SC *ServerConfig -var DB *sql.DB -var PO *PostOffice -var JWTKey []byte - -const TableCreateStatement = ` - CREATE TABLE IF NOT EXISTS users ( - cid INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - password CHAR(60) NOT NULL, - rating TINYINT NOT NULL, - real_name VARCHAR(64) NOT NULL, - creation_time DATETIME NOT NULL - ); - ` - -func StartFSDServer(fsdCtx context.Context) { - // Attempt to resolve the previously-configured listen address - addr, err := net.ResolveTCPAddr("tcp4", SC.FsdListenAddr) - if err != nil { - log.Fatal("Error resolving address: " + err.Error()) - } - - // Attempt to open a listener on that address - listener, err := net.ListenTCP("tcp4", addr) - if err != nil { - log.Fatal("Error listening: " + err.Error()) - } - - // Defer listener close - // This will force the listener goroutine to encounter an error and promptly close itself - defer func() { - closeErr := listener.Close() - if closeErr != nil { - log.Println("Error closing listener: " + closeErr.Error()) - } - }() - - // Listen on a separate goroutine - // connChan shall be closed when the listener closes (due to an error or surrounding context closed) - connChan := make(chan *net.TCPConn) - go func(connChan chan<- *net.TCPConn) { - // Guarantee that connChan will be closed when we return - defer close(connChan) - - for { - // Wait for a connection on the listener - conn, listenerErr := listener.AcceptTCP() - if listenerErr != nil { - return - } - - // Wait for a consumer on connChan, OR return if the context cancels. - select { - case connChan <- conn: - case <-fsdCtx.Done(): - return - } - } - }(connChan) - - log.Println("FSD listening") - - // Poll from connChan, check if the channel is healthy - // Also poll from our context, check if we need to return - for { - select { - case conn, ok := <-connChan: - if !ok { - return - } - go HandleConnection(conn) - case <-fsdCtx.Done(): - return - } - } -} - -func configureProtocolValidator() { - protocol.V = validator.New(validator.WithRequiredStructEnabled()) -} - -func configurePostOffice() { - PO = &PostOffice{ - clientRegistry: make(map[string]*FSDClient), - supervisorRegistry: make(map[string]*FSDClient), - geohashRegistry: make(map[uint64][]*FSDClient), - } -} - -func configureJwt() { - idBytes := make([]byte, 64) - _, err := rand.Read(idBytes) - if err != nil { - log.Fatal(err) - } - JWTKey = idBytes -} - -func configureDatabase() { - db, err := sql.Open("sqlite3", SC.DatabaseFile) - if err != nil { - log.Panic(err) - } - - _, err = db.Exec(TableCreateStatement) - if err != nil { - log.Panic(err) - } - - // Check if the users table is empty - // (is this the first time we've started up using this database?) - usersEmpty, err := IsUsersTableEmpty(db) - if err != nil { - log.Panic(err) - } - - // If it's empty, add a default admin user - if usersEmpty { - buf := make([]byte, 8) // since each byte is 2 hex characters - if _, err = io.ReadFull(rand.Reader, buf); err != nil { - log.Panic(err) - } - - pwd := hex.EncodeToString(buf) - - cid := 100000 - pwdHash, err := bcrypt.GenerateFromPassword([]byte(pwd), 10) - if err != nil { - log.Panic(err) - } - rating := protocol.NetworkRatingADM - realName := "Default Administrator" - - record, err := AddUserRecord(db, cid, string(pwdHash), rating, realName) - if err != nil { - log.Panic(err) - } - - fmt.Printf("Added user: %s CID: %d Password: %s Rating: Administrator\n", record.RealName, record.CID, pwd) - } - - // Set global DB variable - DB = db -} - -func setupServerConfig() { - ctx := context.Background() - - var c ServerConfig - if err := envconfig.Process(ctx, &c); err != nil { - log.Fatal(err) - } - - SC = &c -} - func main() { - setupServerConfig() - configureProtocolValidator() + b := bootstrap.NewDefaultBootstrap() - configureDatabase() - defer DB.Close() + log.Println("Starting services...") - configureJwt() - configurePostOffice() + ctx, cancel := context.WithCancel(context.Background()) + if err := b.Start(ctx); err != nil { + log.Fatalln(err) + } - httpCtx, cancelHttp := context.WithCancel(context.Background()) - go StartHttpServer(httpCtx) - defer cancelHttp() + log.Println("All services started.") - fsdCtx, cancelFsd := context.WithCancel(context.Background()) - go StartFSDServer(fsdCtx) - defer cancelFsd() + // Listen for OS signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - done := make(chan os.Signal, 1) - signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + // Wait for either an error or a shutdown signal + select { + case sig := <-sigCh: + log.Printf("received %s", sig) + cancel() + case <-b.Done: + log.Println("FATAL: a service exited early.") + } - // Wait for OS done signal - <-done + log.Println("Waiting for services to stop...") + <-b.Done + log.Println("All services stopped.") } diff --git a/post_office.go b/post_office.go deleted file mode 100644 index fe35c62..0000000 --- a/post_office.go +++ /dev/null @@ -1,258 +0,0 @@ -package main - -import ( - "errors" - "github.com/mmcloughlin/geohash" - "github.com/renorris/openfsd/protocol" - "sync" -) - -// PostOffice handles the routing of messages between clients -type PostOffice struct { - clientRegistry map[string]*FSDClient - supervisorRegistry map[string]*FSDClient - geohashRegistry map[uint64][]*FSDClient - lock sync.RWMutex -} - -const ( - MailTypeDirect = iota - MailTypeBroadcastRanged - MailTypeBroadcastAll - MailTypeBroadcastSupervisors -) - -// Mail holds messages to be passed between clients -type Mail struct { - Type int - Source *FSDClient - Recipients []string - Packets []string -} - -func NewMail(source *FSDClient) *Mail { - return &Mail{ - Type: 0, - Source: source, - Recipients: nil, - Packets: nil, - } -} - -func (m *Mail) SetType(mailType int) { - m.Type = mailType -} - -func (m *Mail) AddRecipient(callsign string) { - if m.Recipients == nil { - m.Recipients = make([]string, 0) - } - m.Recipients = append(m.Recipients, callsign) -} - -func (m *Mail) AddPacket(packet string) { - if m.Packets == nil { - m.Packets = make([]string, 0, 1) - } - m.Packets = append(m.Packets, packet) -} - -type FSDClientNode struct { - Client *FSDClient - Next *FSDClientNode -} - -var ( - CallsignAlreadyRegisteredError = callsignAlreadyRegisteredError() - CallsignNotRegisteredError = callsignNotRegisteredError() -) - -func callsignAlreadyRegisteredError() error { return errors.New("callsign already registered") } -func callsignNotRegisteredError() error { return errors.New("callsign not registered") } - -// RegisterCallsign registers a callsign to the post office, making it a valid recipient for other clients -func (p *PostOffice) RegisterCallsign(callsign string, client *FSDClient) error { - p.lock.Lock() - defer p.lock.Unlock() - - // Check if callsign already exists in the registry - _, ok := p.clientRegistry[callsign] - if ok { - return CallsignAlreadyRegisteredError - } - - // Otherwise, add the client to the registry - p.clientRegistry[callsign] = client - - // If the client is a supervisor, add them to the supervisor registry - if client.NetworkRating == protocol.NetworkRatingSUP { - p.supervisorRegistry[callsign] = client - } - - return nil -} - -// DeregisterCallsign removes a callsign from the post office. -// Returns CallsignNotRegisteredError if the callsign is not registered. -func (p *PostOffice) DeregisterCallsign(callsign string) error { - p.lock.Lock() - defer p.lock.Unlock() - - // Check if callsign exists in registry - client, ok := p.clientRegistry[callsign] - if !ok { - return CallsignNotRegisteredError - } - - // Delete the entry - delete(p.clientRegistry, callsign) - - // If the client is a supervisor, delete from supervisor registry - if client.NetworkRating == protocol.NetworkRatingSUP { - delete(p.supervisorRegistry, callsign) - } - - // Remove client from geohash registry - p.removeClientFromGeohashRegistry(client) - - return nil -} - -// SendMail forwards Mail to its recipients -func (p *PostOffice) SendMail(messages []Mail) { - p.lock.RLock() - defer p.lock.RUnlock() - - for _, msg := range messages { - switch msg.Type { - case MailTypeDirect: - for _, recipient := range msg.Recipients { - fsdClient, ok := p.clientRegistry[recipient] - // If the callsign doesn't exist, drop the message - if !ok { - continue - } - for _, packet := range msg.Packets { - // Do not block writing to mailbox, avoiding potential deadlock - select { - case fsdClient.Mailbox <- packet: - default: - } - } - } - case MailTypeBroadcastRanged: - neighbors := geohash.NeighborsInt(msg.Source.CurrentGeohash) - searchHashes := append(neighbors, msg.Source.CurrentGeohash) - for _, hash := range searchHashes { - clients, ok := p.geohashRegistry[hash] - if !ok { - continue - } - for _, client := range clients { - if msg.Source != client { - for _, packet := range msg.Packets { - select { - case client.Mailbox <- packet: - default: - } - } - } - } - } - case MailTypeBroadcastAll: - for _, fsdClient := range p.clientRegistry { - if msg.Source != fsdClient { - for _, packet := range msg.Packets { - select { - case fsdClient.Mailbox <- packet: - default: - } - } - } - } - case MailTypeBroadcastSupervisors: - for _, fsdClient := range p.supervisorRegistry { - for _, packet := range msg.Packets { - if msg.Source != fsdClient { - select { - case fsdClient.Mailbox <- packet: - default: - } - } - } - } - } - } -} - -func (p *PostOffice) removeClientFromGeohashRegistry(client *FSDClient) { - clientList, ok := p.geohashRegistry[client.CurrentGeohash] - if !ok { - return - } - - // Attempt to find the client in the list - for i := 0; i < len(clientList); i++ { - if clientList[i] == client { - // Remove the client from the slice - clientList = append(clientList[:i], clientList[i+1:]...) - break - } - } - - // If that was the last client in the list, delete it from the map and return - if len(clientList) == 0 { - delete(p.geohashRegistry, client.CurrentGeohash) - return - } - - // Re-allocate the slice and rewrite the map entry - newClientList := make([]*FSDClient, len(clientList)) - copy(newClientList, clientList) - - p.geohashRegistry[client.CurrentGeohash] = newClientList -} - -// SetLocation updates the internal geohash tracking state for a client, if necessary -func (p *PostOffice) SetLocation(client *FSDClient, lat, lng float64) { - // Check if the geohash has changed since we last updated - hash := geohash.EncodeIntWithPrecision(lat, lng, 15) - if hash == client.CurrentGeohash { - return - } - - p.lock.Lock() - defer p.lock.Unlock() - - // Remove client from old geohash bucket - p.removeClientFromGeohashRegistry(client) - - // Find the new client list - newClientList, ok := p.geohashRegistry[hash] - // Create the slice if necessary - if !ok { - newClientList = make([]*FSDClient, 0) - } - - // Add client to the list - newClientList = append(newClientList, client) - - // Put it back on the registry - p.geohashRegistry[hash] = newClientList - - // Set our new current geohash - client.CurrentGeohash = hash -} - -// GetClient finds an *FSDClient for a callsign string. -func (p *PostOffice) GetClient(callsign string) (*FSDClient, error) { - p.lock.RLock() - defer p.lock.RUnlock() - - client, ok := p.clientRegistry[callsign] - if !ok { - return nil, CallsignNotRegisteredError - } - - return client, nil -} diff --git a/post_office_test.go b/post_office_test.go deleted file mode 100644 index e8c605d..0000000 --- a/post_office_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package main - -import ( - "github.com/renorris/openfsd/protocol" - "github.com/stretchr/testify/assert" - "testing" - "time" -) - -func TestPostOffice(t *testing.T) { - configurePostOffice() - - c1 := FSDClient{ - Conn: nil, - Reader: nil, - AuthVerify: nil, - AuthSelf: nil, - NetworkRating: protocol.NetworkRatingOBS, - Mailbox: make(chan string, 16), - } - - c2 := FSDClient{ - Conn: nil, - Reader: nil, - AuthVerify: nil, - AuthSelf: nil, - NetworkRating: protocol.NetworkRatingSUP, - Mailbox: make(chan string, 16), - } - - // Test RegisterCallsign - - // Register "1" - { - err := PO.RegisterCallsign("1", &c1) - assert.Nil(t, err) - } - - // Register "1" twice - { - err := PO.RegisterCallsign("1", &c1) - assert.NotNil(t, err) - assert.ErrorIs(t, CallsignAlreadyRegisteredError, err) - } - - // Register "2" - { - err := PO.RegisterCallsign("2", &c2) - assert.Nil(t, err) - } - - // Register "2" twice - { - err := PO.RegisterCallsign("2", &c2) - assert.NotNil(t, err) - assert.ErrorIs(t, CallsignAlreadyRegisteredError, err) - } - - // Test SendMail - { - m := Mail{ - Type: MailTypeDirect, - Source: &c1, - Recipients: []string{"2"}, - Packets: []string{"hello"}, - } - - PO.SendMail([]Mail{m}) - - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - - assert.Empty(t, c1.Mailbox) - assert.Empty(t, c2.Mailbox) - } - - { - m := Mail{ - Type: MailTypeBroadcastAll, - Source: &c1, - Recipients: []string{"2"}, - Packets: []string{"hello"}, - } - - PO.SendMail([]Mail{m}) - - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - { - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - } - } - - // Test supervisor message - { - m := Mail{ - Type: MailTypeBroadcastSupervisors, - Source: &c1, - Recipients: nil, - Packets: []string{"hello"}, - } - - PO.SendMail([]Mail{m}) - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - { - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - } - } - - // Test ranged broadcast mail - { - // c1 and c2 in range: - PO.SetLocation(&c1, 45.0, 45.0) - PO.SetLocation(&c2, 45.0, 45.0) - - m := Mail{ - Type: MailTypeBroadcastRanged, - Source: &c1, - Recipients: nil, - Packets: []string{"hello"}, - } - - PO.SendMail([]Mail{m}) - - // c2 should receive the broadcast - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - { - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - } - - // Move c2 far away from c1 - PO.SetLocation(&c2, -45.0, -45.0) - - // Try again, nobody should get anything now - PO.SendMail([]Mail{m}) - - assert.Empty(t, c1.Mailbox) - assert.Empty(t, c2.Mailbox) - - // Move back - PO.SetLocation(&c2, 45.0, 45.0) - - // Should get the message again - PO.SendMail([]Mail{m}) - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - { - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - } - - // Move c2 to a "neighbor" geohash cell - PO.SetLocation(&c2, 45.7, 47.1) - - // c1 geohash: v00 - // c2 geohash: v01 (neighbor) - - PO.SendMail([]Mail{m}) - assert.Empty(t, c1.Mailbox) - assert.NotEmpty(t, c2.Mailbox) - - { - msg := <-c2.Mailbox - assert.Equal(t, "hello", msg) - } - } - - // Test DeregisterCallsign - - // "3" is not registered - { - err := PO.DeregisterCallsign("3") - assert.NotNil(t, err) - assert.ErrorIs(t, CallsignNotRegisteredError, err) - } - - // Unavailable client mailbox should not block SendMail - { - m := Mail{ - Type: MailTypeDirect, - Source: &c1, - Recipients: []string{"2"}, - Packets: []string{"hello"}, - } - - c2.Mailbox = nil - - timer := time.NewTimer(100 * time.Millisecond) - done := make(chan interface{}) - go func() { - PO.SendMail([]Mail{m}) - close(done) - }() - - select { - case <-timer.C: - assert.Fail(t, "SendMail timeout") - case <-done: - } - } - - // Deregister "2" - { - err := PO.DeregisterCallsign("2") - assert.Nil(t, err) - } - - // Deregister "2" twice - { - err := PO.DeregisterCallsign("2") - assert.NotNil(t, err) - assert.ErrorIs(t, CallsignNotRegisteredError, err) - } - - // Deregister "1" - { - err := PO.DeregisterCallsign("1") - assert.Nil(t, err) - } - - // Deregister "1" twice - { - err := PO.DeregisterCallsign("1") - assert.NotNil(t, err) - assert.ErrorIs(t, CallsignNotRegisteredError, err) - } -} diff --git a/postoffice/address.go b/postoffice/address.go new file mode 100644 index 0000000..d5315c0 --- /dev/null +++ b/postoffice/address.go @@ -0,0 +1,37 @@ +package postoffice + +import ( + "errors" + "github.com/renorris/openfsd/protocol" + "time" +) + +// Address represents a routable address for a PostOffice +type Address interface { + Name() string + SendMail(string) + SendKill(string) error + NetworkRating() protocol.NetworkRating + Geohash() Geohash + State() AddressState +} + +// AddressState represents address metadata +type AddressState struct { + CID int + RealName string + PilotRating protocol.PilotRating + Latitude float64 + Longitude float64 + Altitude int + Groundspeed int + Transponder string + Heading int // Degrees magnetic + LastUpdated time.Time // The time this pilot's information was last updated +} + +func addressAlreadyRegisteredError() error { return errors.New("address name in use") } +func addressNotRegisteredError() error { return errors.New("address not registered") } + +var AddressAlreadyRegisteredError = addressAlreadyRegisteredError() +var AddressNotRegisteredError = addressNotRegisteredError() diff --git a/postoffice/geohash_bucket.go b/postoffice/geohash_bucket.go new file mode 100644 index 0000000..c154f7b --- /dev/null +++ b/postoffice/geohash_bucket.go @@ -0,0 +1,56 @@ +package postoffice + +import ( + "slices" + "sync" +) + +// GeohashBucket is a contiguous & concurrency-safe array of addresses +type GeohashBucket struct { + lock sync.RWMutex + addresses []Address +} + +// RLock locks this bucket for read-only access, then returns the slice of addresses to read. +// Conventionally, callers must use RUnlock() once done reading addresses. +func (e *GeohashBucket) RLock() (addresses []Address) { + e.lock.RLock() + return e.addresses +} + +// RUnlock releases the read lock for this entry +func (e *GeohashBucket) RUnlock() { + e.lock.RUnlock() +} + +// Delete deletes an address from this bucket's list. +// Returns the Len of the underlying list after deleting the value. +func (e *GeohashBucket) Delete(address Address) { + e.lock.Lock() + + // Find index of entry to remove + i := slices.Index(e.addresses, address) + if i > -1 { + // Move last element into element we're deleting + e.addresses[i] = e.addresses[len(e.addresses)-1] + + // Change slice header to reflect new Len + e.addresses = e.addresses[:len(e.addresses)-1] + + // Reallocate the slice if the capacity is twice the Len + if len(e.addresses) > 0 && cap(e.addresses)/len(e.addresses) > 1 { + newSlice := make([]Address, len(e.addresses)) + copy(newSlice, e.addresses) + e.addresses = newSlice + } + } + + e.lock.Unlock() +} + +// Add adds an address to this bucket's list +func (e *GeohashBucket) Add(address Address) { + e.lock.Lock() + e.addresses = append(e.addresses, address) + e.lock.Unlock() +} diff --git a/postoffice/geohash_util.go b/postoffice/geohash_util.go new file mode 100644 index 0000000..df38388 --- /dev/null +++ b/postoffice/geohash_util.go @@ -0,0 +1,55 @@ +package postoffice + +import "github.com/mmcloughlin/geohash" + +// Number of bits to encode for a full precision geohash. +// This is the bit-depth of the Geohash() value stored in each Address. +const geohashFullPrecisionBits = 30 + +// Number of bits to utilize for determining which bucket a geohash falls into. +const geohashBucketPrecisionBits = 15 + +// Bits of precision to use for general-proximity broadcasts +const geohashGeneralProximityPrecisionBits = geohashBucketPrecisionBits + +// Bits of precision to use for close-proximity broadcasts +const geohashCloseProximityPrecisionBits = 25 + +type Geohash struct { + val uint64 + precision int +} + +func NewGeohash(lat, lon float64) Geohash { + return Geohash{ + val: geohash.EncodeIntWithPrecision(lat, lon, geohashFullPrecisionBits), + precision: geohashFullPrecisionBits, + } +} + +func NewGeohashManual(hash uint64, precision int) Geohash { + return Geohash{ + val: hash, + precision: precision, + } +} + +func (g Geohash) Neighbors(precision int) []uint64 { + return geohash.NeighborsIntWithPrecision(g.AsPrecision(precision), uint(precision)) +} + +func (g Geohash) Precision() int { + return g.precision +} + +func (g Geohash) Val() uint64 { + return g.val +} + +func (g Geohash) AsPrecision(precision int) uint64 { + shift := g.precision - precision + if shift <= 0 { + return g.val + } + return g.val >> shift +} diff --git a/postoffice/mail.go b/postoffice/mail.go new file mode 100644 index 0000000..3cddf57 --- /dev/null +++ b/postoffice/mail.go @@ -0,0 +1,43 @@ +package postoffice + +// Mail types +const ( + MailTypeDirect = iota + MailTypeBroadcast + MailTypeGeneralProximityBroadcast + MailTypeCloseProximityBroadcast + MailTypeSupervisorBroadcast +) + +// Mail represents a message to be passed between addresses +type Mail struct { + mailType int + source Address + recipient string + packet string +} + +func (m *Mail) Type() int { + return m.mailType +} + +func (m *Mail) Source() Address { + return m.source +} + +func (m *Mail) Recipient() string { + return m.recipient +} + +func (m *Mail) Packet() string { + return m.packet +} + +func NewMail(source Address, mailType int, recipient string, packet string) Mail { + return Mail{ + mailType: mailType, + source: source, + recipient: recipient, + packet: packet, + } +} diff --git a/postoffice/post_office.go b/postoffice/post_office.go new file mode 100644 index 0000000..56ea3a3 --- /dev/null +++ b/postoffice/post_office.go @@ -0,0 +1,123 @@ +package postoffice + +import ( + "github.com/renorris/openfsd/protocol" +) + +// Addresses are placed into geographical buckets. +// A bucket is determined by lowering the precision of an address +// into a 15-bit geohash "bounding box." +// For proximity broadcasts, the caller address's source bucket is determined, +// then each neighbor is sent the message. This does not magically avoid the +// O(n^2) problem, but it shrinks it to the point of disregard in 99.9% of +// reasonable cases. + +// PostOffice handles the routing of messages between clients +type PostOffice struct { + addressRegistry *Registry // Map address name (usually a callsign) to its respective Address + supervisorAddressRegistry *Registry // Map supervisor address name to its respective Address + world *World // World container holding all geohash buckets +} + +func NewPostOffice() *PostOffice { + return &PostOffice{ + addressRegistry: NewRegistry(1024), + supervisorAddressRegistry: NewRegistry(16), + world: NewWorld(), + } +} + +// RegisterAddress registers an address to the post +// office, making it a valid recipient for other addresses. +// Returns KeyInUseError if the address name (callsign) is already in use. +func (p *PostOffice) RegisterAddress(address Address) error { + if err := p.addressRegistry.Store(address.Name(), address); err != nil { + return err + } + + // Always acquire a key in the main address registry first *before* adding anything to the supervisor map. + // On delete, perform the operations in the reverse order: remove from supervisor registry, + // then finally remove from main address registry. + + // If the address is a supervisor, add them to the supervisor registry + if address.NetworkRating() >= protocol.NetworkRatingSUP { + if err := p.supervisorAddressRegistry.Store(address.Name(), address); err != nil { + return err + } + } + + return nil +} + +// DeregisterAddress removes an address from the post office. +func (p *PostOffice) DeregisterAddress(address Address) { + // Remove any supervisor entry *first.* + if address.NetworkRating() >= protocol.NetworkRatingSUP { + p.supervisorAddressRegistry.Delete(address.Name()) + } + + p.addressRegistry.Delete(address.Name()) + + p.removeAddressFromGeohashBucket(address.Geohash().AsPrecision(geohashBucketPrecisionBits), address) +} + +// NumRegistered returns a snapshot of how many addresses were registered at the time of calling +func (p *PostOffice) NumRegistered() int { + return p.addressRegistry.Len() +} + +// ForEachRegistered runs the provided function for each registered address. +// If the function returns false, iteration will cease and return early. +func (p *PostOffice) ForEachRegistered(f func(name string, Address Address) bool) { + p.addressRegistry.ForEach(f) +} + +// SendMail forwards Mail to its recipients +func (p *PostOffice) SendMail(mail *Mail) { + switch mail.Type() { + case MailTypeDirect: + p.sendDirectMail(mail) + case MailTypeGeneralProximityBroadcast: + p.sendProximityMail(mail, geohashGeneralProximityPrecisionBits) + case MailTypeCloseProximityBroadcast: + p.sendProximityMail(mail, geohashCloseProximityPrecisionBits) + case MailTypeBroadcast: + p.sendBroadcastMail(mail) + case MailTypeSupervisorBroadcast: + p.sendSupervisorBroadcastMail(mail) + } +} + +// SetLocation marks where an address is geographically located in order to +// properly handle any future proximity broadcast messages. +// Returns the updated geohash. +func (p *PostOffice) SetLocation(address Address, lat, lng float64) (newGeohash Geohash) { + + // Make a full precision geohash from the caller's coordinates + newGeohash = NewGeohash(lat, lng) + + // Check if the provided coordinates lie outside the address's current geohash + currentBucketHash := address.Geohash().AsPrecision(geohashBucketPrecisionBits) + newBucketHash := newGeohash.AsPrecision(geohashBucketPrecisionBits) + if currentBucketHash == newBucketHash { + return + } + + // Remove the address from the current bucket + p.removeAddressFromGeohashBucket(currentBucketHash, address) + + // Put it into the new bucket + p.addAddressToGeohashBucket(newBucketHash, address) + + return +} + +// Kill finds an address associated with `pdu` and sends a kill signal +func (p *PostOffice) Kill(pdu *protocol.KillRequestPDU) error { + addr, exists := p.addressRegistry.Load(pdu.To) + if !exists { + return AddressNotRegisteredError + } + + return addr.SendKill(pdu.Serialize()) +} diff --git a/postoffice/post_office_test.go b/postoffice/post_office_test.go new file mode 100644 index 0000000..9875b32 --- /dev/null +++ b/postoffice/post_office_test.go @@ -0,0 +1,293 @@ +package postoffice + +import ( + "errors" + "github.com/renorris/openfsd/protocol" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +type testAddress struct { + name string + mailbox chan string + killbox chan string + rating protocol.NetworkRating + geohash Geohash +} + +func (t *testAddress) Name() string { + return t.name +} + +func (t *testAddress) SendMail(s string) { + select { + case t.mailbox <- s: + default: + } +} + +func (t *testAddress) SendKill(s string) error { + select { + case t.killbox <- s: + return nil + default: + return errors.New("killbox defaulted") + } +} + +func (t *testAddress) NetworkRating() protocol.NetworkRating { + return t.rating +} + +func (t *testAddress) Geohash() Geohash { + return t.geohash +} + +func (t *testAddress) State() AddressState { + return AddressState{} +} + +func TestPostOffice(t *testing.T) { + PO := NewPostOffice() + + c1 := testAddress{ + name: "1", + mailbox: make(chan string, 16), + killbox: make(chan string, 16), + rating: protocol.NetworkRatingOBS, + geohash: Geohash{}, + } + + c2 := testAddress{ + name: "2", + mailbox: make(chan string, 16), + killbox: make(chan string, 16), + rating: protocol.NetworkRatingSUP, + geohash: Geohash{}, + } + + // Test RegisterAddress + + // Register "1" + { + err := PO.RegisterAddress(&c1) + assert.Nil(t, err) + } + + // Register "1" twice + { + err := PO.RegisterAddress(&c1) + assert.NotNil(t, err) + assert.ErrorIs(t, KeyInUseError, err) + } + + // Register "2" + { + err := PO.RegisterAddress(&c2) + assert.Nil(t, err) + } + + // Register "2" twice + { + err := PO.RegisterAddress(&c2) + assert.NotNil(t, err) + assert.ErrorIs(t, KeyInUseError, err) + } + + // Test SendMail + { + m := Mail{ + mailType: MailTypeDirect, + source: &c1, + recipient: "2", + packet: "hello", + } + + PO.SendMail(&m) + + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + + assert.Empty(t, c1.mailbox) + assert.Empty(t, c2.mailbox) + } + + { + m := Mail{ + mailType: MailTypeBroadcast, + source: &c1, + recipient: "2", + packet: "hello", + } + + PO.SendMail(&m) + + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + } + + // Test supervisor message + { + m := Mail{ + mailType: MailTypeSupervisorBroadcast, + source: &c1, + packet: "hello", + } + + PO.SendMail(&m) + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + } + + // Test ranged broadcast mail + { + // c1 and c2 in range: + c1.geohash = PO.SetLocation(&c1, 45.0, 45.0) + c2.geohash = PO.SetLocation(&c2, 45.0, 45.0) + + m := Mail{ + mailType: MailTypeGeneralProximityBroadcast, + source: &c1, + packet: "hello", + } + + PO.SendMail(&m) + + // c2 should receive the broadcast + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + + // Move c2 far away from c1 + c2.geohash = PO.SetLocation(&c2, -45.0, -45.0) + + // Try again, nobody should see anything now + PO.SendMail(&m) + assert.Empty(t, c1.mailbox) + assert.Empty(t, c2.mailbox) + + // Move back + c2.geohash = PO.SetLocation(&c2, 45.0, 45.0) + + // Should see the message again + PO.SendMail(&m) + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + + // Move c2 to a general proximity neighbor cell + c2.geohash = PO.SetLocation(&c2, 45.7, 47.1) + + // c1 geohash: v00 + // c2 geohash: v01 (neighbor) + + PO.SendMail(&m) + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + + // Test close proximity broadcast + m2 := Mail{ + mailType: MailTypeCloseProximityBroadcast, + source: &c1, + packet: "hello", + } + + // Set client 1 and client 2 in the same location + c1.geohash = PO.SetLocation(&c1, 32.713026, -117.176283) + c2.geohash = PO.SetLocation(&c2, 32.713026, -117.176283) + + PO.SendMail(&m2) + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + + // Move client 2 to a neighbor close-proximity cell + c2.geohash = PO.SetLocation(&c2, 32.719491, -117.134442) + + // client 2 should still see close-proximity mail + PO.SendMail(&m2) + assert.Empty(t, c1.mailbox) + assert.NotEmpty(t, c2.mailbox) + + { + msg := <-c2.mailbox + assert.Equal(t, "hello", msg) + } + + // Move c2 two close-proximity cells away + c2.geohash = PO.SetLocation(&c2, 32.715297, -117.091257) + + // Nobody should see any mail + PO.SendMail(&m2) + assert.Empty(t, c1.mailbox) + assert.Empty(t, c2.mailbox) + } + + // Unavailable browser mailbox should not block SendMail + { + m := Mail{ + mailType: MailTypeDirect, + source: &c1, + recipient: "2", + packet: "hello", + } + + c2.mailbox = nil + + timer := time.NewTimer(100 * time.Millisecond) + done := make(chan interface{}) + go func() { + PO.SendMail(&m) + close(done) + }() + + select { + case <-timer.C: + assert.Fail(t, "SendMail timeout") + case <-done: + } + } + + // Deregister "2" + PO.DeregisterAddress(&c2) + + // Deregister "2" twice + PO.DeregisterAddress(&c2) + + // Deregister "1" + PO.DeregisterAddress(&c1) + + // Deregister "1" twice + PO.DeregisterAddress(&c1) +} diff --git a/postoffice/post_office_util.go b/postoffice/post_office_util.go new file mode 100644 index 0000000..714f8f7 --- /dev/null +++ b/postoffice/post_office_util.go @@ -0,0 +1,83 @@ +package postoffice + +func (p *PostOffice) sendDirectMail(mail *Mail) { + addr, ok := p.addressRegistry.Load(mail.Recipient()) + if !ok { + // Drop the message if no key is found + return + } + + addr.SendMail(mail.Packet()) +} + +func (p *PostOffice) sendBroadcastMail(mail *Mail) { + p.addressRegistry.ForEach(func(_ string, addr Address) bool { + // Skip self + if addr == mail.Source() { + return true + } + + addr.SendMail(mail.Packet()) + return true + }) +} + +func (p *PostOffice) sendSupervisorBroadcastMail(mail *Mail) { + p.supervisorAddressRegistry.ForEach(func(_ string, addr Address) bool { + // Skip ourselves + if addr == mail.Source() { + return true + } + + addr.SendMail(mail.Packet()) + return true + }) +} + +func (p *PostOffice) sendProximityMail(mail *Mail, precision int) { + self := mail.Source() + source := self.Geohash() + + // Send to our own bucket + bucket := p.world.Bucket(source.AsPrecision(geohashBucketPrecisionBits)) + for _, addr := range bucket.RLock() { + if addr == self { + continue + } + if addr.Geohash().AsPrecision(precision) == source.AsPrecision(precision) { + addr.SendMail(mail.Packet()) + } + } + bucket.RUnlock() + + // Find our neighbors at the desired precision + broadcastNeighbors := source.Neighbors(precision) + + for _, neighbor := range broadcastNeighbors { + // Iterate over each address in the bucket to + // see if it overlaps with the desired precision. + neighborGeohash := NewGeohashManual(neighbor, precision) + bucket = p.world.Bucket(neighborGeohash.AsPrecision(geohashBucketPrecisionBits)) + for _, addr := range bucket.RLock() { + if addr == self { + continue + } + if addr.Geohash().AsPrecision(precision) == neighborGeohash.AsPrecision(precision) { + addr.SendMail(mail.Packet()) + } + } + bucket.RUnlock() + } +} + +// removeAddressFromGeohashBucket removes an address from the geohash bucket `bucketGeohash`. +// `bucketGeohash` must be encoded with geohashBucketPrecisionBits bits. +func (p *PostOffice) removeAddressFromGeohashBucket(bucketGeohash uint64, address Address) { + p.world.Bucket(bucketGeohash).Delete(address) +} + +// addAddressToGeohashBucket adds an address to the geohash bucket `bucketGeohash`. +// `bucketGeohash` must be encoded with geohashBucketPrecisionBits bits. +func (p *PostOffice) addAddressToGeohashBucket(bucketGeohash uint64, address Address) { + p.world.Bucket(bucketGeohash).Add(address) +} diff --git a/postoffice/registry.go b/postoffice/registry.go new file mode 100644 index 0000000..9e9cd91 --- /dev/null +++ b/postoffice/registry.go @@ -0,0 +1,86 @@ +package postoffice + +import ( + "errors" + "sync" +) + +// Registry is a concurrency-safe map. +// It does not allow a key to be overwritten if it already exists. +type Registry struct { + lock sync.RWMutex + registry map[string]Address +} + +// NewRegistry makes a new registry with `alloc` spaces pre-allocated. +func NewRegistry(alloc int) *Registry { + return &Registry{ + lock: sync.RWMutex{}, + registry: make(map[string]Address, alloc), + } +} + +func keyInUseError() error { + return errors.New("key in use") +} + +var KeyInUseError = keyInUseError() + +// Store adds a key/value pair into the registry. +// Returns KeyInUseError if the key is already in use. +func (r *Registry) Store(key string, val Address) error { + r.lock.Lock() + + _, exists := r.registry[key] + if exists { + r.lock.Unlock() + return KeyInUseError + } + r.registry[key] = val + + r.lock.Unlock() + return nil +} + +// Delete removes a key/value pair from the registry. +func (r *Registry) Delete(key string) { + r.lock.Lock() + + delete(r.registry, key) + + r.lock.Unlock() +} + +// Load fetches a value for a given key +func (r *Registry) Load(key string) (addr Address, exists bool) { + r.lock.RLock() + + addr, exists = r.registry[key] + + r.lock.RUnlock() + return +} + +// Len returns the number of keys stored in this registry +func (r *Registry) Len() int { + r.lock.RLock() + + length := len(r.registry) + + r.lock.RUnlock() + return length +} + +// ForEach calls the provided function once for each key in this registry. +// If the function returns false, iteration will cease and return early. +func (r *Registry) ForEach(f func(key string, val Address) bool) { + r.lock.RLock() + + for k, v := range r.registry { + if !f(k, v) { + return + } + } + + r.lock.RUnlock() +} diff --git a/postoffice/world.go b/postoffice/world.go new file mode 100644 index 0000000..ab90ca2 --- /dev/null +++ b/postoffice/world.go @@ -0,0 +1,29 @@ +package postoffice + +// World holds a bucket for every 15-bit precision geohash, +// i.e. splitting the world into 2^15 buckets. +type World struct { + buckets [32768]GeohashBucket +} + +func NewWorld() *World { + buckets := [32768]GeohashBucket{} + + // Initialize all buckets + for i := 0; i < len(buckets); i++ { + buckets[i] = GeohashBucket{ + addresses: make([]Address, 0), + } + } + + return &World{buckets: buckets} +} + +// Bucket returns the geohash bucket for `index` (15-bit geohash) +func (w *World) Bucket(index uint64) *GeohashBucket { + if index < 0 || index >= uint64(len(w.buckets)) { + panic("geohash bucket index out of bounds") + } + + return &w.buckets[index] +} diff --git a/processor.go b/processor.go deleted file mode 100644 index 9f8da4c..0000000 --- a/processor.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "errors" - "github.com/renorris/openfsd/protocol" - "strings" -) - -// ProcessorResult represents the result of a handler function -type ProcessorResult struct { - Replies []string - Mail []Mail - ShouldDisconnect bool -} - -func NewProcessorResult() *ProcessorResult { - return &ProcessorResult{ - Replies: nil, - Mail: nil, - ShouldDisconnect: false, - } -} - -// Disconnect sets the flag to disconnect the client -func (r *ProcessorResult) Disconnect(flag bool) { - r.ShouldDisconnect = flag -} - -// AddReply adds a packet to send back to the client -func (r *ProcessorResult) AddReply(packet string) { - if r.Replies == nil { - r.Replies = make([]string, 0) - } - r.Replies = append(r.Replies, packet) -} - -// AddMail adds mail to be sent to other clients -func (r *ProcessorResult) AddMail(mail Mail) { - if r.Mail == nil { - r.Mail = make([]Mail, 0) - } - r.Mail = append(r.Mail, mail) -} - -// Processor represents a function to process an incoming FSD packet -type Processor func(client *FSDClient, rawPacket string) *ProcessorResult - -// InvalidPacketError means the packet was not recognized by the parser -func newInvalidPacketError() error { - return errors.New("Invalid packet") -} - -var ( - InvalidPacketError = newInvalidPacketError() -) - -func GetProcessor(rawPacket string) (Processor, error) { - rawPacket = strings.TrimSuffix(rawPacket, "\r\n") - if len(rawPacket) < 3 { - return nil, InvalidPacketError - } - - fields := strings.Split(rawPacket, protocol.Delimeter) - - switch rawPacket[0] { - case '^': - return FastPilotPositionProcessor, nil - case '@': - return PilotPositionProcessor, nil - case '#', '$': - pduID := rawPacket[0:3] - switch pduID { - case "$CQ": - return ClientQueryProcessor, nil - case "$CR": - return ClientQueryResponseProcessor, nil - case "#SL": - return FastPilotPositionProcessor, nil - case "#ST": - return FastPilotPositionProcessor, nil - case "$AX": - // TODO: implement METAR request - case "$PI": - return PingProcessor, nil - case "$ZC": - return AuthChallengeProcessor, nil - case "$ZR": - return AuthChallengeResponseProcessor, nil - case "$!!": - return KillRequestProcessor, nil - case "#DP": - return DeletePilotProcessor, nil - case "#SB": - if len(fields) < 3 { - return nil, InvalidPacketError - } - if fields[2] == "PIR" { - return PlaneInfoRequestProcessor, nil - } - if fields[2] == "FSIPIR" { - return PlaneInfoRequestFsinnProcessor, nil - } - if fields[2] == "PI" && len(fields) > 3 && fields[3] == "GEN" { - return PlaneInfoResponseProcessor, nil - } - case "#TM": - if len(fields) < 3 { - return nil, InvalidPacketError - } - switch fields[1] { - case "*": - return BroadcastMessageProcessor, nil - case "*S": - return WallopMessageProcessor, nil - default: - if len(fields[1]) > 0 && fields[1][0:1] == "@" { - return RadioMessageProcessor, nil - } else { - return DirectMessageProcessor, nil - } - } - } - } - - return nil, InvalidPacketError -} diff --git a/processor_defs.go b/processor_defs.go deleted file mode 100644 index ad3caa4..0000000 --- a/processor_defs.go +++ /dev/null @@ -1,656 +0,0 @@ -package main - -import ( - "errors" - "github.com/renorris/openfsd/protocol" - "github.com/renorris/openfsd/vatsimauth" - "log" - "strings" -) - -func PilotPositionProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParsePilotPositionPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Update location for post office - PO.SetLocation(client, pdu.Lat, pdu.Lng) - - result := NewProcessorResult() - - // Check if we should update SendFastEnabled - if client.SendFastEnabled && pdu.GroundSpeed == 0 { - client.SendFastEnabled = false - disableSendFastPDU := protocol.SendFastPDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - DoSendFast: false, - } - result.AddReply(disableSendFastPDU.Serialize()) - } else if !client.SendFastEnabled && pdu.GroundSpeed > 0 { - client.SendFastEnabled = true - enableSendFastPDU := protocol.SendFastPDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - DoSendFast: true, - } - result.AddReply(enableSendFastPDU.Serialize()) - } - - // Broadcast position update - mail := NewMail(client) - mail.SetType(MailTypeBroadcastRanged) - mail.AddPacket(rawPacket) - - result.AddMail(*mail) - return result -} - -func ClientQueryProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseClientQueryPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - switch pdu.To { - case protocol.ServerCallsign: - if pdu.QueryType == protocol.ClientQueryPublicIP { - ip := strings.Split(client.Conn.RemoteAddr().String(), ":")[0] - responsePDU := protocol.ClientQueryResponsePDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - QueryType: protocol.ClientQueryPublicIP, - Payload: ip, - } - result := NewProcessorResult() - result.AddReply(responsePDU.Serialize()) - return result - } - - return NewProcessorResult() - - case protocol.ClientQueryBroadcastRecipient, protocol.ClientQueryBroadcastRecipientPilots: - mail := NewMail(client) - mail.SetType(MailTypeBroadcastRanged) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result - } - - // Assume direct message client query - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result - -} - -func ClientQueryResponseProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseClientQueryResponsePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - if pdu.To == protocol.ServerCallsign { - return NewProcessorResult() - } - - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func PlaneInfoRequestProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParsePlaneInfoRequestPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func PlaneInfoRequestFsinnProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParsePlaneInfoRequestFsinnPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func PlaneInfoResponseProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParsePlaneInfoResponsePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func FastPilotPositionProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseFastPilotPositionPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Update location for post office if slow/stopped type - switch pdu.Type { - case protocol.FastPilotPositionTypeSlow, protocol.FastPilotPositionTypeStopped: - PO.SetLocation(client, pdu.Lat, pdu.Lng) - } - - // Broadcast position update - mail := NewMail(client) - mail.SetType(MailTypeBroadcastRanged) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - return result -} - -func AuthChallengeProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseAuthChallengePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Generate response for the client's challenge - challengeResponse := client.AuthSelf.GenerateResponse(pdu.Challenge) - client.AuthSelf.UpdateState(challengeResponse) - challengeResponsePDU := protocol.AuthChallengeResponsePDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - ChallengeResponse: challengeResponse, - } - - // Send the client a new auth challenge - client.PendingAuthVerifyChallenge, err = vatsimauth.GenerateChallenge() - if err != nil { - log.Println("Error generating challenge string") - result := NewProcessorResult() - result.Disconnect(true) - return result - } - - newChallengePDU := protocol.AuthChallengePDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - Challenge: client.PendingAuthVerifyChallenge, - } - - result := NewProcessorResult() - result.AddReply(challengeResponsePDU.Serialize()) - result.AddReply(newChallengePDU.Serialize()) - - return result -} - -func AuthChallengeResponseProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseAuthChallengeResponsePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Verify the response with the stored pending challenge - challengeResponse := client.AuthVerify.GenerateResponse(client.PendingAuthVerifyChallenge) - if challengeResponse != pdu.ChallengeResponse { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError).Serialize()) - result.Disconnect(true) - return result - } - client.AuthVerify.UpdateState(challengeResponse) - - result := NewProcessorResult() - return result -} - -func DeletePilotProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseDeletePilotPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Check for valid CID - if pdu.CID != client.CID { - result := NewProcessorResult() - - result.AddReply(protocol.NewGenericFSDError(protocol.SyntaxError).Serialize()) - result.Disconnect(true) - - return result - } - - result := NewProcessorResult() - result.Disconnect(true) - - return result -} - -func PingProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParsePingPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Ignore if the ping isn't for the server - if pdu.To != protocol.ServerCallsign { - result := NewProcessorResult() - return result - } - - result := NewProcessorResult() - pongPDU := protocol.PongPDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - Timestamp: pdu.Timestamp, - } - result.AddReply(pongPDU.Serialize()) - - return result -} - -func BroadcastMessageProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseTextMessagePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Ignore if the user doesn't have permission - if client.NetworkRating < protocol.NetworkRatingSUP { - return NewProcessorResult() - } - - // Verify the To field is set to `*` - if pdu.To != "*" { - return NewProcessorResult() - } - - mail := NewMail(client) - mail.SetType(MailTypeBroadcastAll) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func WallopMessageProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseTextMessagePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Verify the To field is set to `*S` - if pdu.To != "*S" { - return NewProcessorResult() - } - - mail := NewMail(client) - mail.SetType(MailTypeBroadcastSupervisors) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func RadioMessageProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseTextMessagePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Verify the To field is a radio frequency - if !strings.HasPrefix(pdu.To, "@") || len(pdu.To) != 6 { - return NewProcessorResult() - } - - mail := NewMail(client) - mail.SetType(MailTypeBroadcastRanged) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func DirectMessageProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseTextMessagePDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - mail := NewMail(client) - mail.SetType(MailTypeDirect) - mail.AddRecipient(pdu.To) - mail.AddPacket(rawPacket) - - result := NewProcessorResult() - result.AddMail(*mail) - - return result -} - -func KillRequestProcessor(client *FSDClient, rawPacket string) *ProcessorResult { - // Parse & validate packet - pdu, err := protocol.ParseKillRequestPDU(rawPacket) - if err != nil { - var fsdError *protocol.FSDError - result := NewProcessorResult() - if errors.As(err, &fsdError) { - result.AddReply(fsdError.Serialize()) - } - result.Disconnect(true) - return result - } - - // Check for valid source callsign - if pdu.From != client.Callsign { - result := NewProcessorResult() - result.AddReply(protocol.NewGenericFSDError(protocol.PDUSourceInvalidError).Serialize()) - result.Disconnect(true) - return result - } - - // Check if client has permission - if client.NetworkRating < protocol.NetworkRatingSUP { - return NewProcessorResult() - } - - victim, err := PO.GetClient(pdu.To) - if err != nil { - result := NewProcessorResult() - if errors.Is(err, CallsignNotRegisteredError) { - result.AddReply(protocol.NewGenericFSDError(protocol.NoSuchCallsignError).Serialize()) - } - return result - } - - result := NewProcessorResult() - replyPDU := protocol.TextMessagePDU{ - From: protocol.ServerCallsign, - To: client.Callsign, - Message: "", - } - - // Attempt a non-blocking kill - select { - case victim.Kill <- rawPacket: - replyPDU.Message = "Killed " + pdu.To - default: - replyPDU.Message = "ERROR: unable to kill " + pdu.To - } - - result.AddReply(replyPDU.Serialize()) - return result -} diff --git a/protocol/add_pilot.go b/protocol/add_pilot.go index fb399bb..e18d863 100644 --- a/protocol/add_pilot.go +++ b/protocol/add_pilot.go @@ -7,63 +7,70 @@ import ( ) type AddPilotPDU struct { - From string `validate:"required,alphanum,max=16"` - To string `validate:"required,alphanum,max=16"` - CID int `validate:"required,min=100000,max=9999999"` - Token string `validate:"required"` - NetworkRating int `validate:"min=1,max=16"` - ProtocolRevision int `validate:""` - SimulatorType int `validate:"min=0,max=32"` - RealName string `validate:"required,max=32"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` + CID int `validate:"required,min=100000,max=9999999"` + Token string `validate:"required"` + NetworkRating NetworkRating `validate:"min=1,max=12"` + ProtocolRevision int `validate:"min=0,max=101"` + SimulatorType int `validate:"min=0,max=32"` + RealName string `validate:"required,max=64"` } func (p *AddPilotPDU) Serialize() string { - return fmt.Sprintf("#AP%s:%s:%d:%s:%d:%d:%d:%s%s", p.From, p.To, p.CID, p.Token, p.NetworkRating, p.ProtocolRevision, p.SimulatorType, p.RealName, PacketDelimeter) + return fmt.Sprintf("#AP%s:%s:%d:%s:%d:%d:%d:%s%s", + p.From, p.To, p.CID, p.Token, p.NetworkRating, p.ProtocolRevision, + p.SimulatorType, p.RealName, PacketDelimiter) } -func ParseAddPilotPDU(packet string) (*AddPilotPDU, error) { - rawPacket := strings.TrimSuffix(string(packet), PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#AP") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 8 { - return nil, NewGenericFSDError(SyntaxError) - } +func (p *AddPilotPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#AP") - cid, err := strconv.Atoi(fields[2]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - networkRating, err := strconv.Atoi(fields[4]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - protocolRevision, err := strconv.Atoi(fields[5]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - simulatorType, err := strconv.Atoi(fields[6]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + // Extract fields + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 8 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := AddPilotPDU{ - From: fields[0], - To: fields[1], - CID: cid, - Token: fields[3], - NetworkRating: networkRating, - ProtocolRevision: protocolRevision, - SimulatorType: simulatorType, - RealName: fields[7], + From: fields[0], + To: fields[1], + Token: fields[3], + RealName: fields[7], } - err = V.Struct(&pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var err error + + // Parse integer fields + if pdu.CID, err = strconv.Atoi(fields[2]); err != nil { + return NewGenericFSDError(SyntaxError, fields[2], "invalid CID") } - return &pdu, nil + var r int + if r, err = strconv.Atoi(fields[4]); err != nil { + return NewGenericFSDError(SyntaxError, fields[4], "invalid network rating") + } + pdu.NetworkRating = NetworkRating(r) + + if pdu.ProtocolRevision, err = strconv.Atoi(fields[5]); err != nil { + return NewGenericFSDError(SyntaxError, fields[5], "invalid protocol revision") + } + + if pdu.SimulatorType, err = strconv.Atoi(fields[6]); err != nil { + return NewGenericFSDError(SyntaxError, fields[6], "invalid simulator type") + } + + // Validate + if err = V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err + } + + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/add_pilot_test.go b/protocol/add_pilot_test.go index 824ec54..dbc90d3 100644 --- a/protocol/add_pilot_test.go +++ b/protocol/add_pilot_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -67,55 +68,60 @@ func TestParseAddPilotPDU(t *testing.T) { { "Invalid CID length", "#APCLIENT:SERVER:12345:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:3:John Smith\r\n", - nil, - NewGenericFSDError(SyntaxError), + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid CID format", "#APCLIENT:SERVER:ABCDE12:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:3:John Smith\r\n", - nil, - NewGenericFSDError(SyntaxError), + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "ABCDE12", "invalid CID"), }, { - "Invalid Network Rating", + "Invalid Network NetworkRating", "#APCLIENT:SERVER:1234567:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:13:2:3:John Smith\r\n", - nil, - NewGenericFSDError(SyntaxError), + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid Simulator Type", - "#APCLIENT:SERVER:1234567:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:7:John Smith\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#APCLIENT:SERVER:1234567:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:999:John Smith\r\n", + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Real name too long", - "#APCLIENT:SERVER:1234567:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:3:This Is Way Too Long For A Full Real Name\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#APCLIENT:SERVER:1234567:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8:5:2:3:" + strings.Repeat("John Appleseed ", 128) + "\r\n", + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing Delimeters", "APCLIENTSERVER1234567eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEwMDAwMDAiLCJhdWQiOlsiZnNkLWxpdmUiXSwiZXhwIjoxNzExOTA4OTM4LCJuYmYiOjE3MTE5MDgzOTgsImlhdCI6MTcxMTkwODUxOCwianRpIjoiRDFCS1BPdUdKelAzZE5NdnV6d1JNZz09IiwiY29udHJvbGxlcl9yYXRpbmciOjAsInBpbG90X3JhdGluZyI6MH0.kg23HhANM6aUI9mRUUGX-Vx8HKjTpzkDxOXlvWkjnC8523John Smith", - nil, - NewGenericFSDError(SyntaxError), + &AddPilotPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseAddPilotPDU(tc.packet) + p := AddPilotPDU{} + err := p.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &p) }) } } diff --git a/protocol/auth_challenge.go b/protocol/auth_challenge.go index 31a78da..f5d5abb 100644 --- a/protocol/auth_challenge.go +++ b/protocol/auth_challenge.go @@ -6,22 +6,22 @@ import ( ) type AuthChallengePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` Challenge string `validate:"required,hexadecimal,min=4,max=32"` } func (p *AuthChallengePDU) Serialize() string { - return fmt.Sprintf("$ZC%s:%s:%s%s", p.From, p.To, p.Challenge, PacketDelimeter) + return fmt.Sprintf("$ZC%s:%s:%s%s", p.From, p.To, p.Challenge, PacketDelimiter) } -func ParseAuthChallengePDU(rawPacket string) (*AuthChallengePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$ZC") - fields := strings.Split(rawPacket, Delimeter) +func (p *AuthChallengePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$ZC") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := AuthChallengePDU{ @@ -30,10 +30,15 @@ func ParseAuthChallengePDU(rawPacket string) (*AuthChallengePDU, error) { Challenge: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/auth_challenge_response.go b/protocol/auth_challenge_response.go index f1728e0..fd36fff 100644 --- a/protocol/auth_challenge_response.go +++ b/protocol/auth_challenge_response.go @@ -6,22 +6,22 @@ import ( ) type AuthChallengeResponsePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` ChallengeResponse string `validate:"required,hexadecimal,md5"` } func (p *AuthChallengeResponsePDU) Serialize() string { - return fmt.Sprintf("$ZR%s:%s:%s%s", p.From, p.To, p.ChallengeResponse, PacketDelimeter) + return fmt.Sprintf("$ZR%s:%s:%s%s", p.From, p.To, p.ChallengeResponse, PacketDelimiter) } -func ParseAuthChallengeResponsePDU(rawPacket string) (*AuthChallengeResponsePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$ZR") - fields := strings.Split(rawPacket, Delimeter) +func (p *AuthChallengeResponsePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$ZR") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := AuthChallengeResponsePDU{ @@ -30,10 +30,15 @@ func ParseAuthChallengeResponsePDU(rawPacket string) (*AuthChallengeResponsePDU, ChallengeResponse: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/auth_challenge_response_test.go b/protocol/auth_challenge_response_test.go index a705009..da0055f 100644 --- a/protocol/auth_challenge_response_test.go +++ b/protocol/auth_challenge_response_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -54,26 +55,26 @@ func TestParseAuthChallengeResponsePDU(t *testing.T) { { name: "Missing From Field", packet: "$ZR:CLIENT:0c4a96fa1cab961018620f120988cdf9\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengeResponsePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing To Field", packet: "$ZRSERVER::0c4a96fa1cab961018620f120988cdf9\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengeResponsePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Challenge response not md5", packet: "$ZRSERVER:CLIENT:0c4a96fa1cab9610\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengeResponsePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid Hexadecimal", packet: "$ZRSERVER:CLIENT:ghij7890\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengeResponsePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } @@ -81,9 +82,20 @@ func TestParseAuthChallengeResponsePDU(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := ParseAuthChallengeResponsePDU(tc.packet) - assert.Equal(t, tc.want, result) - assert.Equal(t, tc.wantErr, err) + pdu := AuthChallengeResponsePDU{} + err := pdu.Parse(tc.packet) + assert.Equal(t, tc.want, &pdu) + + // Check the error + if tc.wantErr != nil { + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } + } else { + assert.NoError(t, err) + } }) } } diff --git a/protocol/auth_challenge_test.go b/protocol/auth_challenge_test.go index 7ebfce3..66af13d 100644 --- a/protocol/auth_challenge_test.go +++ b/protocol/auth_challenge_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -54,26 +55,26 @@ func TestParseAuthChallengePDU(t *testing.T) { { name: "Missing From Field", packet: "$ZC:CLIENT:abcd1234ef\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing To Field", packet: "$ZCSERVER::abcd1234ef\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { - name: "Invalid Challenge Length", + name: "Invalid Challenge Len", packet: "$ZCSERVER:CLIENT:ab\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid Hexadecimal Challenge", packet: "$ZCSERVER:CLIENT:ghij7890\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &AuthChallengePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } @@ -81,9 +82,19 @@ func TestParseAuthChallengePDU(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := ParseAuthChallengePDU(tc.packet) - assert.Equal(t, tc.want, result) - assert.Equal(t, tc.wantErr, err) + pdu := AuthChallengePDU{} + err := pdu.Parse(tc.packet) + assert.Equal(t, tc.want, &pdu) + // Check the error + if tc.wantErr != nil { + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } + } else { + assert.NoError(t, err) + } }) } } diff --git a/protocol/client_identification.go b/protocol/client_identification.go index d5223dd..64ca0ee 100644 --- a/protocol/client_identification.go +++ b/protocol/client_identification.go @@ -13,10 +13,10 @@ type ClientIdentificationPDU struct { To string `validate:"required,alphanum,max=16"` ClientID uint16 `validate:"required"` ClientName string `validate:"required,max=32"` - MajorVersion int `validate:""` - MinorVersion int `validate:""` - CID int `validate:"required,min=100000,max=9999999"` - SysUID int `validate:"required,number"` + MajorVersion int `validate:"min=0,max=999"` + MinorVersion int `validate:"min=0,max=999"` + CID int `validate:"min=100000,max=9999999"` + SysUID int `validate:""` InitialChallenge string `validate:"required,hexadecimal,min=2,max=32"` } @@ -24,64 +24,65 @@ func (p *ClientIdentificationPDU) Serialize() string { clientIDBytes := make([]byte, 2) binary.BigEndian.PutUint16(clientIDBytes, p.ClientID) clientIDStr := hex.EncodeToString(clientIDBytes) - return fmt.Sprintf("$ID%s:%s:%s:%s:%d:%d:%d:%d:%s%s", p.From, p.To, clientIDStr, p.ClientName, p.MajorVersion, p.MinorVersion, p.CID, p.SysUID, p.InitialChallenge, PacketDelimeter) + return fmt.Sprintf("$ID%s:%s:%s:%s:%d:%d:%d:%d:%s%s", + p.From, p.To, clientIDStr, p.ClientName, p.MajorVersion, + p.MinorVersion, p.CID, p.SysUID, p.InitialChallenge, PacketDelimiter) } -func ParseClientIdentificationPDU(rawPacket string) (*ClientIdentificationPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$ID") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 9 { - return nil, NewGenericFSDError(SyntaxError) - } +func (p *ClientIdentificationPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$ID") - // fields[2] == uint16 in hexadecimal - if len(fields[2]) != 4 { - return nil, NewGenericFSDError(SyntaxError) - } - - clientIDBytes, err := hex.DecodeString(fields[2]) - if err != nil || len(clientIDBytes) != 2 { - return nil, NewGenericFSDError(SyntaxError) - } - clientID := binary.BigEndian.Uint16(clientIDBytes) - - majorVersion, err := strconv.Atoi(fields[4]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - minorVersion, err := strconv.Atoi(fields[5]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - cid, err := strconv.Atoi(fields[6]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - sysUID, err := strconv.Atoi(fields[7]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 9 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := ClientIdentificationPDU{ From: fields[0], To: fields[1], - ClientID: clientID, ClientName: fields[3], - MajorVersion: majorVersion, - MinorVersion: minorVersion, - CID: cid, - SysUID: sysUID, InitialChallenge: fields[8], } - err = V.Struct(&pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + // fields[2] == uint16 in hexadecimal + if len(fields[2]) != 4 { + return NewGenericFSDError(SyntaxError, fields[2], + "client ID must be 4 hexadecimal characters") } - return &pdu, nil + var clientIDBytes []byte + var err error + if clientIDBytes, err = hex.DecodeString(fields[2]); err != nil || len(clientIDBytes) != 2 { + return NewGenericFSDError(SyntaxError, fields[2], "invalid client ID") + } + pdu.ClientID = binary.BigEndian.Uint16(clientIDBytes) + + if pdu.MajorVersion, err = strconv.Atoi(fields[4]); err != nil { + return NewGenericFSDError(SyntaxError, fields[4], "invalid major version") + } + + if pdu.MinorVersion, err = strconv.Atoi(fields[5]); err != nil { + return NewGenericFSDError(SyntaxError, fields[5], "invalid minor version") + } + + if pdu.CID, err = strconv.Atoi(fields[6]); err != nil { + return NewGenericFSDError(SyntaxError, fields[6], "invalid CID") + } + + if pdu.SysUID, err = strconv.Atoi(fields[7]); err != nil { + return NewGenericFSDError(SyntaxError, fields[7], "invalid system UID") + } + + if err = V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err + } + + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/client_identification_test.go b/protocol/client_identification_test.go index cd177ed..9c87adb 100644 --- a/protocol/client_identification_test.go +++ b/protocol/client_identification_test.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -35,76 +36,81 @@ func TestParseClientIdentificationPDU(t *testing.T) { { name: "Missing Field", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:1234567:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { name: "Invalid Major Version", packet: "$IDCLIENT:SERVER:1234:ClientName:X:2:0001234:12345678:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "X", "invalid major version"), }, { name: "Invalid ClientID", packet: "$IDCLIENT:SERVER:12:ClientName:1:2:0001234:12345678:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "12", "client ID must be 4 hexadecimal characters"), }, { name: "Invalid Hexadecimal in Initial Challenge", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:0001234:12345678:xyz\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Initial Challenge Too Short", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:0001234:12345678:a\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid Minor Version", packet: "$IDCLIENT:SERVER:1234:ClientName:1:XY:0001234:12345678:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "XY", "invalid minor version"), }, { name: "Invalid SysUID - Non-numeric", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:0001234:SYSUID:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "SYSUID", "invalid system UID"), }, { name: "Invalid CID - too long", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:0001234567890:12345678:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid CID - contains letters", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:000ABCD:12345678:abcd1234\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "000ABCD", "invalid CID"), }, { name: "Initial Challenge Too Long", packet: "$IDCLIENT:SERVER:1234:ClientName:1:2:0001234:12345678:" + hex.EncodeToString(make([]byte, 33)) + "\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &ClientIdentificationPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := ParseClientIdentificationPDU(tc.packet) + pdu := ClientIdentificationPDU{} + err := pdu.Parse(tc.packet) if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/client_query.go b/protocol/client_query.go index 2ad94b5..a860056 100644 --- a/protocol/client_query.go +++ b/protocol/client_query.go @@ -6,43 +6,47 @@ import ( ) type ClientQueryPDU struct { - From string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` To string `validate:"required,max=7"` - QueryType string `validate:"required,ascii,min=2,max=7"` + QueryType string `validate:"required,ascii,min=2,max=16"` Payload string `validate:""` } func (p *ClientQueryPDU) Serialize() string { if p.Payload == "" { - return fmt.Sprintf("$CQ%s:%s:%s%s", p.From, p.To, p.QueryType, PacketDelimeter) + return fmt.Sprintf("$CQ%s:%s:%s%s", + p.From, p.To, p.QueryType, PacketDelimiter) } else { - return fmt.Sprintf("$CQ%s:%s:%s:%s%s", p.From, p.To, p.QueryType, p.Payload, PacketDelimeter) + return fmt.Sprintf("$CQ%s:%s:%s:%s%s", + p.From, p.To, p.QueryType, p.Payload, PacketDelimiter) } } -func ParseClientQueryPDU(rawPacket string) (*ClientQueryPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$CQ") - fields := strings.SplitN(rawPacket, Delimeter, 4) - if len(fields) < 3 { - return nil, NewGenericFSDError(SyntaxError) - } +func (p *ClientQueryPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$CQ") - payload := "" - if len(fields) == 4 { - payload = fields[3] + // Extract fields + var fields []string + if fields = strings.SplitN(packet, Delimiter, 4); len(fields) < 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := ClientQueryPDU{ From: fields[0], To: fields[1], QueryType: fields[2], - Payload: payload, } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if len(fields) == 4 { + pdu.Payload = fields[3] + } + + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } switch pdu.QueryType { @@ -57,8 +61,11 @@ func ParseClientQueryPDU(rawPacket string) (*ClientQueryPDU, error) { "NEWINFO", "NEWATIS", "EST", "GD": default: - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "invalid query type") } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/client_query_response.go b/protocol/client_query_response.go index 4191cb6..685f388 100644 --- a/protocol/client_query_response.go +++ b/protocol/client_query_response.go @@ -6,50 +6,67 @@ import ( ) type ClientQueryResponsePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,max=16"` QueryType string `validate:"required,ascii,min=2,max=7"` Payload string `validate:""` } func (p *ClientQueryResponsePDU) Serialize() string { if p.Payload == "" { - return fmt.Sprintf("$CR%s:%s:%s%s", p.From, p.To, p.QueryType, PacketDelimeter) + return fmt.Sprintf("$CR%s:%s:%s%s", + p.From, p.To, p.QueryType, PacketDelimiter) } else { - return fmt.Sprintf("$CR%s:%s:%s:%s%s", p.From, p.To, p.QueryType, p.Payload, PacketDelimeter) + return fmt.Sprintf("$CR%s:%s:%s:%s%s", + p.From, p.To, p.QueryType, p.Payload, PacketDelimiter) } } -func ParseClientQueryResponsePDU(rawPacket string) (*ClientQueryResponsePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$CR") - fields := strings.SplitN(rawPacket, Delimeter, 4) - if len(fields) < 3 { - return nil, NewGenericFSDError(SyntaxError) - } +func (p *ClientQueryResponsePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$CR") - payload := "" - if len(fields) == 4 { - payload = fields[3] + // Extract fields + var fields []string + if fields = strings.SplitN(packet, Delimiter, 4); len(fields) < 3 { + return NewGenericFSDError(SyntaxError, "", + "invalid parameter count") } pdu := ClientQueryResponsePDU{ From: fields[0], To: fields[1], QueryType: fields[2], - Payload: payload, } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if len(fields) == 4 { + pdu.Payload = fields[3] + } + + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } switch pdu.QueryType { - case "ATC", "CAPS", "C?", "RN", "SV", "ATIS", "IP", "INF", "FP", "IPC", "BY", "HI", "HLP", "NOHLP", "WH", "IT", "HT", "DR", "FA", "TA", "BC", "SC", "VT", "ACC", "NEWINFO", "NEWATIS", "EST", "GD": + case "ATC", "CAPS", "C?", + "RN", "SV", "ATIS", + "IP", "INF", "FP", + "IPC", "BY", "HI", + "HLP", "NOHLP", "WH", + "IT", "HT", "DR", + "FA", "TA", "BC", + "SC", "VT", "ACC", + "NEWINFO", "NEWATIS", "EST", + "GD": default: - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "invalid query type") } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/client_query_response_test.go b/protocol/client_query_response_test.go index 90afbe5..e4592c0 100644 --- a/protocol/client_query_response_test.go +++ b/protocol/client_query_response_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -40,49 +41,54 @@ func TestParseClientQueryResponsePDU(t *testing.T) { { "Invalid QueryType", "$CRFROM:TO:XYZ\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryResponsePDU{}, + NewGenericFSDError(SyntaxError, "XYZ", "invalid query type"), }, { "From field too long", - "$CRFROMFROM:TO:WH\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$CRFROMFROMFROMFROMFROMFROMFROM:TO:WH\r\n", + &ClientQueryResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing QueryType", "$CRFROM:TO:\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Empty packet", "\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Missing From field", "$CR:TO:WH\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseClientQueryResponsePDU(tc.packet) + pdu := ClientQueryResponsePDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/client_query_test.go b/protocol/client_query_test.go index 3e4045a..21d8ed9 100644 --- a/protocol/client_query_test.go +++ b/protocol/client_query_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -40,49 +41,54 @@ func TestParseClientQueryPDU(t *testing.T) { { "Invalid QueryType", "$CQFROM:TO:XYZ\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryPDU{}, + NewGenericFSDError(SyntaxError, "XYZ", "invalid query type"), }, { "From field too long", - "$CQFROMFROM:TO:WH\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$CQFROMFROMFROMFROMFROMFROMFROM:TO:WH\r\n", + &ClientQueryPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing QueryType", "$CQFROM:TO:\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Empty packet", "\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Missing From field", "$CQ:TO:WH\r\n", - nil, - NewGenericFSDError(SyntaxError), + &ClientQueryPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseClientQueryPDU(tc.packet) + pdu := ClientQueryPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/delete_pilot.go b/protocol/delete_pilot.go index 6a96e35..f112672 100644 --- a/protocol/delete_pilot.go +++ b/protocol/delete_pilot.go @@ -7,37 +7,41 @@ import ( ) type DeletePilotPDU struct { - From string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` CID int `validate:"required,min=100000,max=9999999"` } func (p *DeletePilotPDU) Serialize() string { - return fmt.Sprintf("#DP%s:%d%s", p.From, p.CID, PacketDelimeter) + return fmt.Sprintf("#DP%s:%d%s", p.From, p.CID, PacketDelimiter) } -func ParseDeletePilotPDU(rawPacket string) (*DeletePilotPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#DP") - fields := strings.Split(rawPacket, Delimeter) +func (p *DeletePilotPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#DP") - if len(fields) != 2 { - return nil, NewGenericFSDError(SyntaxError) - } - - cid, err := strconv.Atoi(fields[1]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 2 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := DeletePilotPDU{ From: fields[0], - CID: cid, } - err = V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var err error + if pdu.CID, err = strconv.Atoi(fields[1]); err != nil { + return NewGenericFSDError(SyntaxError, fields[1], "invalid CID") } - return &pdu, nil + if err = V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err + } + + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/delete_pilot_test.go b/protocol/delete_pilot_test.go index 83d1be7..b1d6d44 100644 --- a/protocol/delete_pilot_test.go +++ b/protocol/delete_pilot_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -26,50 +27,55 @@ func TestParseDeletePilotPDU(t *testing.T) { }, { "Invalid From Field (Too Long)", - "#DPCONTROLLER1:1234567\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#DPCONTROLLER1CONTROLLER1CONTROLLER1CONTROLLER1:1234567\r\n", + &DeletePilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid CID Field (Non-Numeric)", "#DPCTRLLL:ABCDEF1\r\n", - nil, - NewGenericFSDError(SyntaxError), + &DeletePilotPDU{}, + NewGenericFSDError(SyntaxError, "ABCDEF1", "invalid CID"), }, { - "Invalid CID Field (Wrong Length)", + "Invalid CID Field (Wrong Len)", "#DPCTRLLL:12345\r\n", - nil, - NewGenericFSDError(SyntaxError), + &DeletePilotPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Extra Fields", "#DPCTRLLL:1234567:ExtraData\r\n", - nil, - NewGenericFSDError(SyntaxError), + &DeletePilotPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Missing CID", "#DPCTRLLL:\r\n", - nil, - NewGenericFSDError(SyntaxError), + &DeletePilotPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid CID"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseDeletePilotPDU(tc.packet) + pdu := DeletePilotPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/fast_pilot_position.go b/protocol/fast_pilot_position.go index 9be070b..242447b 100644 --- a/protocol/fast_pilot_position.go +++ b/protocol/fast_pilot_position.go @@ -20,7 +20,7 @@ type VelocityVector struct { type FastPilotPositionPDU struct { Type int `validate:"min=0,max=2"` - From string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` Lat float64 `validate:"min=-90.0,max=90.0"` Lng float64 `validate:"min=-180.0,max=180.0"` AltitudeTrue float64 `validate:"min=-1500.0,max=99999.0"` @@ -36,143 +36,132 @@ type FastPilotPositionPDU struct { func (p *FastPilotPositionPDU) Serialize() string { switch p.Type { case FastPilotPositionTypeFast: - return fmt.Sprintf("^%s:%.6f:%.6f:%.2f:%.2f:%d:%.4f:%.4f:%.4f:%.4f:%.4f:%.4f:%.2f%s", p.From, p.Lat, p.Lng, p.AltitudeTrue, p.AltitudeAgl, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), p.PositionalVelocityVector.X, p.PositionalVelocityVector.Y, p.PositionalVelocityVector.Z, p.RotationalVelocityVector.X, p.RotationalVelocityVector.Y, p.RotationalVelocityVector.Z, p.NoseGearAngle, PacketDelimeter) + return fmt.Sprintf("^%s:%.6f:%.6f:%.2f:%.2f:%d:%.4f:%.4f:%.4f:%.4f:%.4f:%.4f:%.2f%s", + p.From, p.Lat, p.Lng, p.AltitudeTrue, + p.AltitudeAgl, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), + p.PositionalVelocityVector.X, p.PositionalVelocityVector.Y, + p.PositionalVelocityVector.Z, p.RotationalVelocityVector.X, + p.RotationalVelocityVector.Y, p.RotationalVelocityVector.Z, + p.NoseGearAngle, PacketDelimiter) + case FastPilotPositionTypeSlow: - return fmt.Sprintf("#SL%s:%.6f:%.6f:%.2f:%.2f:%d:%.4f:%.4f:%.4f:%.4f:%.4f:%.4f:%.2f%s", p.From, p.Lat, p.Lng, p.AltitudeTrue, p.AltitudeAgl, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), p.PositionalVelocityVector.X, p.PositionalVelocityVector.Y, p.PositionalVelocityVector.Z, p.RotationalVelocityVector.X, p.RotationalVelocityVector.Y, p.RotationalVelocityVector.Z, p.NoseGearAngle, PacketDelimeter) + return fmt.Sprintf("#SL%s:%.6f:%.6f:%.2f:%.2f:%d:%.4f:%.4f:%.4f:%.4f:%.4f:%.4f:%.2f%s", + p.From, p.Lat, p.Lng, p.AltitudeTrue, + p.AltitudeAgl, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), + p.PositionalVelocityVector.X, p.PositionalVelocityVector.Y, + p.PositionalVelocityVector.Z, p.RotationalVelocityVector.X, + p.RotationalVelocityVector.Y, p.RotationalVelocityVector.Z, + p.NoseGearAngle, PacketDelimiter) + default: // FastPilotPositionTypeStopped - return fmt.Sprintf("#ST%s:%.6f:%.6f:%.2f:%.2f:%d:%.2f%s", p.From, p.Lat, p.Lng, p.AltitudeTrue, p.AltitudeAgl, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), p.NoseGearAngle, PacketDelimeter) + return fmt.Sprintf("#ST%s:%.6f:%.6f:%.2f:%.2f:%d:%.2f%s", + p.From, p.Lat, p.Lng, p.AltitudeTrue, p.AltitudeAgl, + packPitchBankHeading(p.Pitch, p.Bank, p.Heading), + p.NoseGearAngle, PacketDelimiter) } } -func ParseFastPilotPositionPDU(rawPacket string) (*FastPilotPositionPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) +func (p *FastPilotPositionPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + // Determine type var pduType int - if strings.HasPrefix(rawPacket, "^") { + if strings.HasPrefix(packet, "^") { pduType = FastPilotPositionTypeFast - rawPacket = strings.TrimPrefix(rawPacket, "^") - } else if strings.HasPrefix(rawPacket, "#SL") { + packet = strings.TrimPrefix(packet, "^") + } else if strings.HasPrefix(packet, "#SL") { pduType = FastPilotPositionTypeSlow - rawPacket = strings.TrimPrefix(rawPacket, "#SL") - } else if strings.HasPrefix(rawPacket, "#ST") { + packet = strings.TrimPrefix(packet, "#SL") + } else if strings.HasPrefix(packet, "#ST") { pduType = FastPilotPositionTypeStopped - rawPacket = strings.TrimPrefix(rawPacket, "#ST") + packet = strings.TrimPrefix(packet, "#ST") } else { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, "", "invalid packet prefix") } - fields := strings.Split(rawPacket, Delimeter) - + fields := strings.Split(packet, Delimiter) if pduType == FastPilotPositionTypeStopped { if len(fields) != 7 { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } } else { if len(fields) != 13 { - return nil, NewGenericFSDError(SyntaxError) - } - } - - lat, err := strconv.ParseFloat(fields[1], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - lng, err := strconv.ParseFloat(fields[2], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - altTrue, err := strconv.ParseFloat(fields[3], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - altAgl, err := strconv.ParseFloat(fields[4], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - pbh, err := strconv.ParseUint(fields[5], 10, 32) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - pitch, bank, heading := unpackPitchBankHeading(uint32(pbh)) - - var positionalVector VelocityVector - var rotationalVector VelocityVector - var noseGearAngle float64 - if pduType == FastPilotPositionTypeStopped { - positionalVector = VelocityVector{ - X: 0, - Y: 0, - Z: 0, - } - rotationalVector = VelocityVector{ - X: 0, - Y: 0, - Z: 0, - } - noseGearAngle, err = strconv.ParseFloat(fields[6], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - } else { - positionalVector.X, err = strconv.ParseFloat(fields[6], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - positionalVector.Y, err = strconv.ParseFloat(fields[7], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - positionalVector.Z, err = strconv.ParseFloat(fields[8], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - rotationalVector.X, err = strconv.ParseFloat(fields[9], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - rotationalVector.Y, err = strconv.ParseFloat(fields[10], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - rotationalVector.Z, err = strconv.ParseFloat(fields[11], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - noseGearAngle, err = strconv.ParseFloat(fields[12], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } } pdu := FastPilotPositionPDU{ - Type: pduType, - From: fields[0], - Lat: lat, - Lng: lng, - AltitudeTrue: altTrue, - AltitudeAgl: altAgl, - Pitch: pitch, - Heading: heading, - Bank: bank, - PositionalVelocityVector: positionalVector, - RotationalVelocityVector: rotationalVector, - NoseGearAngle: noseGearAngle, + Type: pduType, + From: fields[0], } - err = V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var err error + + // Parse numeric fields + if pdu.Lat, err = strconv.ParseFloat(fields[1], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[1], "invalid latitude") } - return &pdu, nil + if pdu.Lng, err = strconv.ParseFloat(fields[2], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[2], "invalid longitude") + } + + if pdu.AltitudeTrue, err = strconv.ParseFloat(fields[3], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[3], "invalid true altitude") + } + + if pdu.AltitudeAgl, err = strconv.ParseFloat(fields[4], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[4], "invalid above-ground-level altitude") + } + + // Parse pitch/bank/heading + var pbh uint64 + if pbh, err = strconv.ParseUint(fields[5], 10, 32); err != nil { + return NewGenericFSDError(SyntaxError, fields[5], "invalid pitch/bank/heading integer") + } + pdu.Pitch, pdu.Bank, pdu.Heading = unpackPitchBankHeading(uint32(pbh)) + + if pduType == FastPilotPositionTypeStopped { + // Set zero values for velocity and rotational vectors + pdu.PositionalVelocityVector = VelocityVector{} + pdu.RotationalVelocityVector = VelocityVector{} + if pdu.NoseGearAngle, err = strconv.ParseFloat(fields[6], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[6], "invalid nose gear angle") + } + } else { + // Parse values + if pdu.PositionalVelocityVector.X, err = strconv.ParseFloat(fields[6], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[6], "invalid positional X velocity vector") + } + if pdu.PositionalVelocityVector.Y, err = strconv.ParseFloat(fields[7], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[7], "invalid positional Y velocity vector") + } + if pdu.PositionalVelocityVector.Z, err = strconv.ParseFloat(fields[8], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[8], "invalid positional Z velocity vector") + } + if pdu.RotationalVelocityVector.X, err = strconv.ParseFloat(fields[9], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[9], "invalid rotational X velocity vector") + } + if pdu.RotationalVelocityVector.Y, err = strconv.ParseFloat(fields[10], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[10], "invalid rotational Y velocity vector") + } + if pdu.RotationalVelocityVector.Z, err = strconv.ParseFloat(fields[11], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[11], "invalid rotational Z velocity vector") + } + if pdu.NoseGearAngle, err = strconv.ParseFloat(fields[12], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[12], "invalid nose gear angle") + } + } + + if err = V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err + } + + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/fast_pilot_position_test.go b/protocol/fast_pilot_position_test.go index e99c07b..1087b67 100644 --- a/protocol/fast_pilot_position_test.go +++ b/protocol/fast_pilot_position_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -72,26 +73,26 @@ func TestParseFastPilotPositionPDU(t *testing.T) { { "Invalid Type", "?PILOT:12.345678:98.765432:300.00:50.00:4177408112:123.4567:345.6789:-234.5678:111.2222:-333.4444:555.6666:90.00\r\n", - nil, - NewGenericFSDError(SyntaxError), + &FastPilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid packet prefix"), }, { "Out of range value", "^PILOT:92.000000:678.765432:300.00:50.00:4177408112:123.4567:345.6789:-234.5678:111.2222:-333.4444:555.6666:90.00\r\n", - nil, - NewGenericFSDError(SyntaxError), + &FastPilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing field", "^PILOT:12.345678:98.765432:300.00:50.00:4177408112:123.4567:345.6789:-234.5678:111.2222:-333.4444\r\n", - nil, - NewGenericFSDError(SyntaxError), + &FastPilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Mismatched type with fields", "#STPILOT:12.345678:98.765432:300.00:50.00:4177408112:123.4567:345.6789:-234.5678:111.2222:-333.4444:555.6666:90.00\n\r\n", - nil, - NewGenericFSDError(SyntaxError), + &FastPilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Valid Stopped Type", @@ -125,34 +126,39 @@ func TestParseFastPilotPositionPDU(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseFastPilotPositionPDU(tc.packet) + pdu := FastPilotPositionPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result if tc.want != nil { - assert.Equal(t, tc.want.From, result.From) - assert.InDelta(t, tc.want.Lat, result.Lat, 1e-6) - assert.InDelta(t, tc.want.Lng, result.Lng, 1e-6) - assert.InDelta(t, tc.want.AltitudeTrue, result.AltitudeTrue, 1e-2) - assert.InDelta(t, tc.want.AltitudeAgl, result.AltitudeAgl, 1e-2) - assert.InDelta(t, tc.want.Pitch, result.Pitch, 1) - assert.InDelta(t, tc.want.Bank, result.Bank, 1) - assert.InDelta(t, tc.want.Heading, result.Heading, 1) - assert.InDelta(t, tc.want.PositionalVelocityVector.X, result.PositionalVelocityVector.X, 1e-4) - assert.InDelta(t, tc.want.PositionalVelocityVector.Y, result.PositionalVelocityVector.Y, 1e-4) - assert.InDelta(t, tc.want.PositionalVelocityVector.Z, result.PositionalVelocityVector.Z, 1e-4) - assert.InDelta(t, tc.want.RotationalVelocityVector.X, result.RotationalVelocityVector.X, 1e-4) - assert.InDelta(t, tc.want.RotationalVelocityVector.Y, result.RotationalVelocityVector.Y, 1e-4) - assert.InDelta(t, tc.want.RotationalVelocityVector.Z, result.RotationalVelocityVector.Z, 1e-4) - assert.InDelta(t, tc.want.NoseGearAngle, result.NoseGearAngle, 1e-2) + assert.Equal(t, tc.want.From, pdu.From) + assert.InDelta(t, tc.want.Lat, pdu.Lat, 1e-6) + assert.InDelta(t, tc.want.Lng, pdu.Lng, 1e-6) + assert.InDelta(t, tc.want.AltitudeTrue, pdu.AltitudeTrue, 1e-2) + assert.InDelta(t, tc.want.AltitudeAgl, pdu.AltitudeAgl, 1e-2) + assert.InDelta(t, tc.want.Pitch, pdu.Pitch, 1) + assert.InDelta(t, tc.want.Bank, pdu.Bank, 1) + assert.InDelta(t, tc.want.Heading, pdu.Heading, 1) + assert.InDelta(t, tc.want.PositionalVelocityVector.X, pdu.PositionalVelocityVector.X, 1e-4) + assert.InDelta(t, tc.want.PositionalVelocityVector.Y, pdu.PositionalVelocityVector.Y, 1e-4) + assert.InDelta(t, tc.want.PositionalVelocityVector.Z, pdu.PositionalVelocityVector.Z, 1e-4) + assert.InDelta(t, tc.want.RotationalVelocityVector.X, pdu.RotationalVelocityVector.X, 1e-4) + assert.InDelta(t, tc.want.RotationalVelocityVector.Y, pdu.RotationalVelocityVector.Y, 1e-4) + assert.InDelta(t, tc.want.RotationalVelocityVector.Z, pdu.RotationalVelocityVector.Z, 1e-4) + assert.InDelta(t, tc.want.NoseGearAngle, pdu.NoseGearAngle, 1e-2) } else { - assert.Nil(t, result) + assert.Nil(t, &pdu) } }) } diff --git a/protocol/fsd_error.go b/protocol/fsd_error.go index 031b780..5b49d5d 100644 --- a/protocol/fsd_error.go +++ b/protocol/fsd_error.go @@ -1,7 +1,9 @@ package protocol import ( + "errors" "fmt" + "github.com/go-playground/validator/v10" "strconv" "strings" ) @@ -33,7 +35,7 @@ const ( var genericErrorMessage = map[ErrorCode]string{ OkError: "OK", - CallsignInUseError: "Callsign already in use", + CallsignInUseError: "callsign already in use", CallsignInvalidError: "Invalid callsign", AlreadyRegisteredError: "Already registered", SyntaxError: "Syntax error", @@ -61,24 +63,24 @@ type FSDError struct { } func (e *FSDError) Error() string { - return genericErrorMessage[e.Code] + return e.Serialize() } func (e *FSDError) Serialize() string { - return fmt.Sprintf("$ER%s:%s:%03d:%s:%s%s", e.From, e.To, e.Code, e.Param, e.Message, PacketDelimeter) + return fmt.Sprintf("$ER%s:%s:%03d:%s:%s%s", e.From, e.To, e.Code, e.Param, e.Message, PacketDelimiter) } func ParseNetworkErrorPDU(rawPacket string) (*FSDError, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) + rawPacket = strings.TrimSuffix(rawPacket, PacketDelimiter) rawPacket = strings.TrimPrefix(rawPacket, "$ER") - fields := strings.SplitN(rawPacket, Delimeter, 5) + fields := strings.SplitN(rawPacket, Delimiter, 5) if len(fields) < 5 { - return nil, NewGenericFSDError(SyntaxError) + return nil, NewGenericFSDError(SyntaxError, "", "invalid field count") } errCode, err := strconv.Atoi(fields[2]) if err != nil { - return nil, NewGenericFSDError(SyntaxError) + return nil, NewGenericFSDError(SyntaxError, fields[2], "invalid error code") } return &FSDError{ @@ -90,12 +92,29 @@ func ParseNetworkErrorPDU(rawPacket string) (*FSDError, error) { }, nil } -func NewGenericFSDError(code ErrorCode) *FSDError { +func NewGenericFSDError(code ErrorCode, param string, messageContext string) *FSDError { + msg := genericErrorMessage[code] + if messageContext != "" { + msg += fmt.Sprintf(": %s", messageContext) + } + return &FSDError{ From: "server", To: "unknown", Code: code, - Param: "", - Message: genericErrorMessage[code], + Param: param, + Message: msg, } } + +func getFSDErrorFromValidatorErrors(err error) *FSDError { + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) { + if len(validationErrors) < 1 { + return nil + } + return NewGenericFSDError(SyntaxError, "", "validation error: "+validationErrors[0].Error()) + } + + return nil +} diff --git a/protocol/kill_request.go b/protocol/kill_request.go index c139b2f..9221dde 100644 --- a/protocol/kill_request.go +++ b/protocol/kill_request.go @@ -6,33 +6,31 @@ import ( ) type KillRequestPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` Reason string `validate:"max=256"` } func (p *KillRequestPDU) Serialize() string { if p.Reason == "" { - return fmt.Sprintf("$!!%s:%s%s", p.From, p.To, PacketDelimeter) + return fmt.Sprintf("$!!%s:%s%s", p.From, p.To, PacketDelimiter) } else { - return fmt.Sprintf("$!!%s:%s:%s%s", p.From, p.To, p.Reason, PacketDelimeter) + return fmt.Sprintf("$!!%s:%s:%s%s", p.From, p.To, p.Reason, PacketDelimiter) } } -func ParseKillRequestPDU(rawPacket string) (*KillRequestPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$!!") - fields := strings.SplitN(rawPacket, Delimeter, 3) +func (p *KillRequestPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$!!") - if len(fields) < 2 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.SplitN(packet, Delimiter, 3); len(fields) < 2 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } var reason string if len(fields) == 3 { reason = fields[2] - } else { - reason = "" } pdu := KillRequestPDU{ @@ -41,10 +39,15 @@ func ParseKillRequestPDU(rawPacket string) (*KillRequestPDU, error) { Reason: reason, } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/kill_request_test.go b/protocol/kill_request_test.go index 762ea23..bfa2d88 100644 --- a/protocol/kill_request_test.go +++ b/protocol/kill_request_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -37,38 +38,43 @@ func TestParseKillRequestPDU(t *testing.T) { }, { name: "Invalid from field", - packet: "$!!JOHN99999:DOE:you're banned: reason\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$!!JOHN99999JOHN99999JOHN99999JOHN99999:DOE:you're banned: reason\r\n", + want: &KillRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid to field", - packet: "$!!JOHN:DOE1234567:you're banned: reason\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$!!JOHN:DOE1234567DOE1234567DOE1234567DOE1234567:you're banned: reason\r\n", + want: &KillRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing to field", packet: "$!!JOHN::Hello, world!\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &KillRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseKillRequestPDU(tc.packet) + pdu := KillRequestPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/metar_request.go b/protocol/metar_request.go index 1be32e7..6609d0a 100644 --- a/protocol/metar_request.go +++ b/protocol/metar_request.go @@ -6,26 +6,26 @@ import ( ) type MetarRequestPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` Station string `validate:"required,alphanum,max=4"` } func (p *MetarRequestPDU) Serialize() string { - return fmt.Sprintf("$AX%s:%s:METAR:%s%s", p.From, p.To, p.Station, PacketDelimeter) + return fmt.Sprintf("$AX%s:%s:METAR:%s%s", p.From, p.To, p.Station, PacketDelimiter) } -func ParseMetarRequestPDU(rawPacket string) (*MetarRequestPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$AX") - fields := strings.Split(rawPacket, Delimeter) +func (p *MetarRequestPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$AX") - if len(fields) != 4 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 4 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } if fields[2] != "METAR" { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "third parameter must be 'METAR'") } pdu := MetarRequestPDU{ @@ -34,10 +34,15 @@ func ParseMetarRequestPDU(rawPacket string) (*MetarRequestPDU, error) { Station: fields[3], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/metar_request_test.go b/protocol/metar_request_test.go index 29e1807..c5c6e24 100644 --- a/protocol/metar_request_test.go +++ b/protocol/metar_request_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -28,44 +29,44 @@ func TestMetarRequestPDU_SerializationAndParsing(t *testing.T) { { "Invalid From (too long)", nil, - "$AXPILOT123:ATC01:METAR:KJFK\r\n", - NewGenericFSDError(SyntaxError), + "$AXPILOT123PILOT123PILOT123PILOT123PILOT123:ATC01:METAR:KJFK\r\n", + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid To (not alphanumeric)", nil, "$AXPILOT1:AT*C1:METAR:KJFK\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid Station (too long)", nil, "$AXPILOT1:ATC01:METAR:KJFKKK\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing Station", nil, "$AXPILOT1:ATC01:METAR:\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid Command", nil, "$AXPILOT1:ATC01:NOTAM:KJFK\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "NOTAM", "third parameter must be 'METAR'"), }, { "Extra fields", nil, "$AXPILOT1:ATC01:METAR:KJFK:EXTRA\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Missing Delimiters", nil, "PILOT1ATC01METARKJFK\r\n", - NewGenericFSDError(SyntaxError), + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, } @@ -78,18 +79,23 @@ func TestMetarRequestPDU_SerializationAndParsing(t *testing.T) { } // Perform parsing - result, err := ParseMetarRequestPDU(tc.rawPacket) + pdu := MetarRequestPDU{} + err := pdu.Parse(tc.rawPacket) // Check the error if tc.expectedError != nil { - assert.EqualError(t, err, tc.expectedError.Error(), "errors should match expected output for case '%s'", tc.name) + if strings.Contains(tc.expectedError.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.expectedError.Error()) + } } else { - assert.NoError(t, err, "no error should occur for case '%s'", tc.name) + assert.NoError(t, err) } // Verify the result if tc.pduInstance != nil { - assert.Equal(t, tc.pduInstance, result, "parsed result should match expected PDU for case '%s'", tc.name) + assert.Equal(t, tc.pduInstance, &pdu, "parsed result should match expected PDU for case '%s'", tc.name) } }) } diff --git a/protocol/metar_response.go b/protocol/metar_response.go index 2b91880..9579475 100644 --- a/protocol/metar_response.go +++ b/protocol/metar_response.go @@ -6,22 +6,22 @@ import ( ) type MetarResponsePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` - Metar string `validate:"required,max=256"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` + Metar string `validate:"required,max=512"` } func (p *MetarResponsePDU) Serialize() string { - return fmt.Sprintf("$AR%s:%s:%s%s", p.From, p.To, p.Metar, PacketDelimeter) + return fmt.Sprintf("$AR%s:%s:%s%s", p.From, p.To, p.Metar, PacketDelimiter) } -func ParseMetarResponsePDU(rawPacket string) (*MetarResponsePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$AR") - fields := strings.SplitN(rawPacket, Delimeter, 3) +func (p *MetarResponsePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$AR") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.SplitN(packet, Delimiter, 3); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := MetarResponsePDU{ @@ -30,10 +30,15 @@ func ParseMetarResponsePDU(rawPacket string) (*MetarResponsePDU, error) { Metar: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/metar_response_test.go b/protocol/metar_response_test.go index 0f31ef1..80cccb4 100644 --- a/protocol/metar_response_test.go +++ b/protocol/metar_response_test.go @@ -39,49 +39,54 @@ func TestParseMetarResponsePDU(t *testing.T) { { "Missing To field", "$ARSERVER::KSEE 091847Z 25007KT 10SM SKC 24/04 A3006\r\n", - nil, - NewGenericFSDError(SyntaxError), + &MetarResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "From Field too long", - "$ARSERVERTOLONG:CLIENT:KSEE 091847Z 25007KT 10SM SKC 24/04 A3006\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$ARSERVERTOLONGSERVERTOLONGSERVERTOLONG:CLIENT:KSEE 091847Z 25007KT 10SM SKC 24/04 A3006\r\n", + &MetarResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Metar too long", - "$ARSERVER:CLIENT:" + strings.Repeat("A", 257) + "\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$ARSERVER:CLIENT:" + strings.Repeat("A", 1024) + "\r\n", + &MetarResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Incomplete packet format", "$ARSERVER:CLIENT\r\n", - nil, - NewGenericFSDError(SyntaxError), + &MetarResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Empty metar field", "$ARSERVER:CLIENT:\r\n", - nil, - NewGenericFSDError(SyntaxError), + &MetarResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseMetarResponsePDU(tc.packet) + pdu := MetarResponsePDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/network_rating.go b/protocol/network_rating.go new file mode 100644 index 0000000..d1aa2a5 --- /dev/null +++ b/protocol/network_rating.go @@ -0,0 +1,82 @@ +package protocol + +type NetworkRating int + +const ( + NetworkRatingINAC = iota - 1 + NetworkRatingSUS + NetworkRatingOBS + NetworkRatingS1 + NetworkRatingS2 + NetworkRatingS3 + NetworkRatingC1 + NetworkRatingC2 + NetworkRatingC3 + NetworkRatingI1 + NetworkRatingI2 + NetworkRatingI3 + NetworkRatingSUP + NetworkRatingADM +) + +var networkRatingToLongString = map[NetworkRating]string{ + -1: "Inactive", + 0: "Suspended", + 1: "Observer", + 2: "Tower Trainee", + 3: "Tower Controller", + 4: "Senior Student", + 5: "Enroute Controller", + 6: "Controller 2", + 7: "Senior Controller", + 8: "Instructor", + 9: "Instructor 2", + 10: "Senior Instructor", + 11: "Supervisor", + 12: "Administrator", +} + +var networkRatingToShortString = map[NetworkRating]string{ + -1: "INAC", + 0: "SUS", + 1: "OBS", + 2: "S1", + 3: "S2", + 4: "S3", + 5: "C1", + 6: "C2", + 7: "C3", + 8: "I1", + 9: "I2", + 10: "I3", + 11: "SUP", + 12: "ADM", +} + +func (n NetworkRating) String() string { + str, ok := networkRatingToLongString[n] + if !ok { + return "" + } + + return str +} + +func (n NetworkRating) ShortString() string { + str, ok := networkRatingToShortString[n] + if !ok { + return "" + } + + return str +} + +func ForEachNetworkRating(f func(id NetworkRating, shortString, longString string)) { + for k, v := range networkRatingToShortString { + f(k, v, networkRatingToLongString[k]) + } +} + +func (n NetworkRating) IsSupervisorOrAbove() bool { + return n >= NetworkRatingSUP +} diff --git a/protocol/pdu.go b/protocol/pdu.go index ad2204e..9cf97f0 100644 --- a/protocol/pdu.go +++ b/protocol/pdu.go @@ -8,8 +8,8 @@ var V *validator.Validate const ( ClientQueryBroadcastRecipient = "@94835" ClientQueryBroadcastRecipientPilots = "@94386" - Delimeter = ":" - PacketDelimeter = "\r\n" + Delimiter = ":" + PacketDelimiter = "\r\n" ServerCallsign = "SERVER" ) @@ -23,22 +23,6 @@ const ( NetworkFacilityCTR ) -const ( - NetworkRatingUnknown = iota - NetworkRatingOBS - NetworkRatingS1 - NetworkRatingS2 - NetworkRatingS3 - NetworkRatingC1 - NetworkRatingC2 - NetworkRatingC3 - NetworkRatingI1 - NetworkRatingI2 - NetworkRatingI3 - NetworkRatingSUP - NetworkRatingADM -) - const ( SimulatorTypeUnknown = iota SimulatorTypeMSFS95 @@ -99,5 +83,6 @@ const ( ) type PDU interface { + Parse(string) error Serialize() string } diff --git a/protocol/pilot_position.go b/protocol/pilot_position.go index 828d3d3..1d22ce0 100644 --- a/protocol/pilot_position.go +++ b/protocol/pilot_position.go @@ -9,7 +9,7 @@ import ( type PilotPositionPDU struct { SquawkingModeC bool `validate:""` Identing bool `validate:""` - From string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` SquawkCode string `validate:"len=4"` NetworkRating int `validate:"required,min=1,max=12"` Lat float64 `validate:"min=-90.0,max=90.0"` @@ -76,94 +76,87 @@ func (p *PilotPositionPDU) Serialize() string { xpdrStateStr = "N" } - return fmt.Sprintf("@%s:%s:%s:%d:%.6f:%.6f:%d:%d:%d:%d%s", xpdrStateStr, p.From, p.SquawkCode, p.NetworkRating, p.Lat, p.Lng, p.TrueAltitude, p.GroundSpeed, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), p.PressureAltitude-p.TrueAltitude, PacketDelimeter) + return fmt.Sprintf("@%s:%s:%s:%d:%.6f:%.6f:%d:%d:%d:%d%s", xpdrStateStr, p.From, p.SquawkCode, p.NetworkRating, p.Lat, p.Lng, p.TrueAltitude, p.GroundSpeed, packPitchBankHeading(p.Pitch, p.Bank, p.Heading), p.PressureAltitude-p.TrueAltitude, PacketDelimiter) } -func ParsePilotPositionPDU(rawPacket string) (*PilotPositionPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "@") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 10 { - return nil, NewGenericFSDError(SyntaxError) - } +func (p *PilotPositionPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "@") - networkRating, err := strconv.Atoi(fields[3]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - lat, err := strconv.ParseFloat(fields[4], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - lng, err := strconv.ParseFloat(fields[5], 64) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - trueAlt, err := strconv.Atoi(fields[6]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - groundspeed, err := strconv.Atoi(fields[7]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - - pbh64, err := strconv.ParseUint(fields[8], 10, 32) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - pbh := uint32(pbh64) - - pitch, bank, heading := unpackPitchBankHeading(pbh) - - pressureAlt, err := strconv.Atoi(fields[9]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - pressureAlt += trueAlt - - identing := false - modeC := false - if fields[0] == "N" { - modeC = true - } else if fields[0] == "Y" { - modeC = true - identing = true - } else if fields[0] != "S" { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 10 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := PilotPositionPDU{ - SquawkingModeC: modeC, - Identing: identing, - From: fields[1], - SquawkCode: fields[2], - NetworkRating: networkRating, - Lat: lat, - Lng: lng, - TrueAltitude: trueAlt, - PressureAltitude: pressureAlt, - GroundSpeed: groundspeed, - Pitch: pitch, - Heading: bank, - Bank: heading, + From: fields[1], + SquawkCode: fields[2], } - err = V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var err error + + // Parse numeric fields + if pdu.NetworkRating, err = strconv.Atoi(fields[3]); err != nil { + return NewGenericFSDError(SyntaxError, fields[3], "invalid network rating") + } + + if pdu.Lat, err = strconv.ParseFloat(fields[4], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[4], "invalid latitude") + } + + if pdu.Lng, err = strconv.ParseFloat(fields[5], 64); err != nil { + return NewGenericFSDError(SyntaxError, fields[5], "invalid longitude") + } + + if pdu.TrueAltitude, err = strconv.Atoi(fields[6]); err != nil { + return NewGenericFSDError(SyntaxError, fields[6], "invalid true altitude") + } + + if pdu.GroundSpeed, err = strconv.Atoi(fields[7]); err != nil { + return NewGenericFSDError(SyntaxError, fields[7], "invalid groundspeed") + } + + if pdu.PressureAltitude, err = strconv.Atoi(fields[9]); err != nil { + return NewGenericFSDError(SyntaxError, fields[9], "invalid pressure altitude") + } + + pdu.PressureAltitude += pdu.TrueAltitude + + // Parse pitch/bank/heading + var pbh64 uint64 + if pbh64, err = strconv.ParseUint(fields[8], 10, 32); err != nil { + return NewGenericFSDError(SyntaxError, fields[8], "invalid pitch/bank/heading integer") + } + pdu.Pitch, pdu.Bank, pdu.Heading = unpackPitchBankHeading(uint32(pbh64)) + + switch fields[0] { + case "N": + pdu.SquawkingModeC = true + case "Y": + pdu.SquawkingModeC = true + pdu.Identing = true + case "S": + default: + return NewGenericFSDError(SyntaxError, fields[0], "invalid transponder state identifier") + } + + // Validate + if err = V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } // Check transponder code validity for _, char := range pdu.SquawkCode { if char < '0' || char > '7' { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "invalid transponder code") } } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/pilot_position_test.go b/protocol/pilot_position_test.go index 7a1252d..7fc2b2f 100644 --- a/protocol/pilot_position_test.go +++ b/protocol/pilot_position_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -78,208 +79,213 @@ func TestParsePilotPositionPDU(t *testing.T) { { "Invalid transponder mode", "@foo:N123:1200:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "foo", "invalid transponder state identifier"), }, { "Missing transponder mode", "@:N123:1200:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid transponder state identifier"), }, { - "Invalid callsign", - "@N:N172SP99:1200:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + "From field too long", + "@N:N172SP99N172SP99N172SP99N172SP99:1200:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing callsign", "@N::1200:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid transponder code length", "@N:N123:00000:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Out of range transponder code", "@N:N123:7778:1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "7778", "invalid transponder code"), }, { "Missing transponder code", "@N:N123::1:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Network rating too low", "@N:N123:1200:0:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Network rating too high", "@N:N123:1200:13:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "NaN network rating", "@N:N123:1200:foo:40.6452667:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "foo", "invalid network rating"), }, { "Out of bounds lat", "@N:N123:1200:1:-91.1231:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing lat", "@N:N123:1200:1::-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid latitude"), }, { "NaN lat", "@N:N123:1200:1:foo:-73.7738611:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "foo", "invalid latitude"), }, { "Out of bounds lng", "@N:N123:1200:1:40.6452667:181.32133:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing lng", "@N:N123:1200:1:40.6452667::16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid longitude"), }, { "NaN lng", "@N:N123:1200:1:40.6452667:foo:16:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "foo", "invalid longitude"), }, { "Too low true alt", "@N:N123:1200:1:40.6452667:-73.7738611:-1501:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Too high true alt", "@N:N123:1200:1:40.6452667:-73.7738611:100000:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing true alt", "@N:N123:1200:1:40.6452667:-73.7738611::0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid true altitude"), }, { "NaN true alt", "@N:N123:1200:1:40.6452667:-73.7738611:foo:0:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "foo", "invalid true altitude"), }, { "Negative groundspeed", "@N:N123:1200:1:40.6452667:-73.7738611:16:-1:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Unrealistic groundspeed", "@N:N123:1200:1:40.6452667:-73.7738611:16:10000:4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing groundspeed", "@N:N123:1200:1:40.6452667:-73.7738611:16::4177408112:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid groundspeed"), }, { "pbh uint32 too long", "@N:N123:1200:1:40.6452667:-73.7738611:16:0:4294967296:336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "4294967296", "invalid pitch/bank/heading integer"), }, { "pbh missing", "@N:N123:1200:1:40.6452667:-73.7738611:16:0::336\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid pitch/bank/heading integer"), }, { "Pressure alt too high", "@N:N123:1200:1:40.6452667:-73.7738611:16:0:4177408112:999999\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Pressure alt too low", "@N:N123:1200:1:40.6452667:-73.7738611:16:0:4177408112:-10016\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Missing pressure alt", "@N:N123:1200:1:40.6452667:-73.7738611:16:0:4177408112:\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid pressure altitude"), }, { "Empty packet", "@\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PilotPositionPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParsePilotPositionPDU(tc.packet) + pdu := PilotPositionPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result if tc.want != nil { - assert.Equal(t, tc.want.SquawkingModeC, result.SquawkingModeC) - assert.Equal(t, tc.want.Identing, result.Identing) - assert.Equal(t, tc.want.From, result.From) - assert.Equal(t, tc.want.SquawkCode, result.SquawkCode) - assert.Equal(t, tc.want.NetworkRating, result.NetworkRating) - assert.InDelta(t, tc.want.Lat, result.Lat, 1e-3) - assert.InDelta(t, tc.want.Lng, result.Lng, 1e-3) - assert.Equal(t, tc.want.TrueAltitude, result.TrueAltitude) - assert.Equal(t, tc.want.GroundSpeed, result.GroundSpeed) - assert.InDelta(t, tc.want.Pitch, result.Pitch, 1) - assert.InDelta(t, tc.want.Bank, result.Bank, 1) - assert.InDelta(t, tc.want.Heading, result.Heading, 1) - assert.Equal(t, tc.want.PressureAltitude, result.PressureAltitude) + assert.Equal(t, tc.want.SquawkingModeC, pdu.SquawkingModeC) + assert.Equal(t, tc.want.Identing, pdu.Identing) + assert.Equal(t, tc.want.From, pdu.From) + assert.Equal(t, tc.want.SquawkCode, pdu.SquawkCode) + assert.Equal(t, tc.want.NetworkRating, pdu.NetworkRating) + assert.InDelta(t, tc.want.Lat, pdu.Lat, 1e-3) + assert.InDelta(t, tc.want.Lng, pdu.Lng, 1e-3) + assert.Equal(t, tc.want.TrueAltitude, pdu.TrueAltitude) + assert.Equal(t, tc.want.GroundSpeed, pdu.GroundSpeed) + assert.InDelta(t, tc.want.Pitch, pdu.Pitch, 1) + assert.InDelta(t, tc.want.Bank, pdu.Bank, 1) + assert.InDelta(t, tc.want.Heading, pdu.Heading, 1) + assert.Equal(t, tc.want.PressureAltitude, pdu.PressureAltitude) } else { - assert.Nil(t, result) + assert.Nil(t, &pdu) } }) } diff --git a/protocol/pilot_rating.go b/protocol/pilot_rating.go new file mode 100644 index 0000000..12f0c2e --- /dev/null +++ b/protocol/pilot_rating.go @@ -0,0 +1,39 @@ +package protocol + +type PilotRating int + +const ( + PilotRatingNEW = 0 + PilotRatingPPL = 1 + PilotRatingIR = 3 + PilotRatingCMEL = 7 + PilotRatingATPL = 15 + PilotRatingFI = 31 + PilotRatingFE = 63 +) + +var pilotRatingToLongString = map[PilotRating]string{ + 0: "Basic Member", + 1: "Private Pilot License", + 3: "Instrument Rating", + 7: "Commercial Multi-Engine License", + 15: "Airline Transport Pilot License", + 31: "Flight Instructor", + 63: "Flight Examiner", +} + +var pilotRatingToShortString = map[PilotRating]string{ + 0: "NEW", + 1: "PPL", + 3: "IR", + 7: "CMEL", + 15: "ATPL", + 31: "FI", + 63: "FE", +} + +func ForEachPilotRating(f func(id PilotRating, shortString, longString string)) { + for k, v := range pilotRatingToShortString { + f(k, v, pilotRatingToLongString[k]) + } +} diff --git a/protocol/ping.go b/protocol/ping.go index a7ffbd4..5449d11 100644 --- a/protocol/ping.go +++ b/protocol/ping.go @@ -6,22 +6,22 @@ import ( ) type PingPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` Timestamp string `validate:"required,max=32"` } func (p *PingPDU) Serialize() string { - return fmt.Sprintf("$PI%s:%s:%s%s", p.From, p.To, p.Timestamp, PacketDelimeter) + return fmt.Sprintf("$PI%s:%s:%s%s", p.From, p.To, p.Timestamp, PacketDelimiter) } -func ParsePingPDU(rawPacket string) (*PingPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$PI") - fields := strings.Split(rawPacket, Delimeter) +func (p *PingPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$PI") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := PingPDU{ @@ -30,10 +30,15 @@ func ParsePingPDU(rawPacket string) (*PingPDU, error) { Timestamp: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/ping_test.go b/protocol/ping_test.go index 4c2d2aa..d637f76 100644 --- a/protocol/ping_test.go +++ b/protocol/ping_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -52,40 +53,45 @@ func TestParsePingPDU(t *testing.T) { { name: "Missing fields", packet: "$PISOURCE:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PingPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { name: "Exceeds max field length", - packet: "$PISOURCESOURCE:TARGETTARGET:16094592000000000000000000000000\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$PISOURCESOURCE:TARGETTARGET:" + strings.Repeat("METAR", 1024) + "\r\n", + want: &PingPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid From field", - packet: "$PI12345678:TARGET:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$PI12345678123456781234567812345678:TARGET:1609459200\r\n", + want: &PingPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid To field", - packet: "$PISOURCE:12345678:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$PISOURCE:1234567812345678123456781234567812345678:1609459200\r\n", + want: &PingPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := ParsePingPDU(tc.packet) + pdu := PingPDU{} + err := pdu.Parse(tc.packet) if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/plane_info_request.go b/protocol/plane_info_request.go index 8ab7b43..c53d0ea 100644 --- a/protocol/plane_info_request.go +++ b/protocol/plane_info_request.go @@ -6,35 +6,41 @@ import ( ) type PlaneInfoRequestPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` } func (p *PlaneInfoRequestPDU) Serialize() string { - return fmt.Sprintf("#SB%s:%s:PIR%s", p.From, p.To, PacketDelimeter) + return fmt.Sprintf("#SB%s:%s:PIR%s", p.From, p.To, PacketDelimiter) } -func ParsePlaneInfoRequestPDU(rawPacket string) (*PlaneInfoRequestPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#SB") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) +func (p *PlaneInfoRequestPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#SB") + + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } if fields[2] != "PIR" { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "third parameter must be 'PIR'") } - pdu := &PlaneInfoRequestPDU{ + pdu := PlaneInfoRequestPDU{ From: fields[0], To: fields[1], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/plane_info_request_fsinn.go b/protocol/plane_info_request_fsinn.go index 4d28009..838f97d 100644 --- a/protocol/plane_info_request_fsinn.go +++ b/protocol/plane_info_request_fsinn.go @@ -6,31 +6,32 @@ import ( ) type PlaneInfoRequestFsinnPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` - AirlineICAO string `validate:"alphanum,max=4"` - AircraftICAO string `validate:"alphanum,max=4"` - AircraftICAOCombinedType string `validate:"alphanum,max=4"` - SendMModel string `validate:"max=128"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` + AirlineICAO string `validate:"min=0,max=4"` + AircraftICAO string `validate:"min=0,max=4"` + AircraftICAOCombinedType string `validate:"min=0,max=4"` + SendMModel string `validate:"max=256"` } func (p *PlaneInfoRequestFsinnPDU) Serialize() string { - return fmt.Sprintf("#SB%s:%s:FSIPIR:0:%s:%s:::::%s:%s%s", p.From, p.To, p.AirlineICAO, p.AircraftICAO, p.AircraftICAOCombinedType, p.SendMModel, PacketDelimeter) + return fmt.Sprintf("#SB%s:%s:FSIPIR:0:%s:%s:::::%s:%s%s", p.From, p.To, p.AirlineICAO, p.AircraftICAO, p.AircraftICAOCombinedType, p.SendMModel, PacketDelimiter) } -func ParsePlaneInfoRequestFsinnPDU(rawPacket string) (*PlaneInfoRequestFsinnPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#SB") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 12 { - return nil, NewGenericFSDError(SyntaxError) +func (p *PlaneInfoRequestFsinnPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#SB") + + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 12 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } if fields[2] != "FSIPIR" { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "third parameter must be 'FSIPIR'") } - pdu := &PlaneInfoRequestFsinnPDU{ + pdu := PlaneInfoRequestFsinnPDU{ From: fields[0], To: fields[1], AirlineICAO: fields[4], @@ -39,10 +40,15 @@ func ParsePlaneInfoRequestFsinnPDU(rawPacket string) (*PlaneInfoRequestFsinnPDU, SendMModel: fields[11], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(&pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return pdu, nil + // Copy new pdu into receiever + *p = pdu + + return nil } diff --git a/protocol/plane_info_request_fsinn_test.go b/protocol/plane_info_request_fsinn_test.go index b6f6291..38047ce 100644 --- a/protocol/plane_info_request_fsinn_test.go +++ b/protocol/plane_info_request_fsinn_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -30,50 +31,49 @@ func TestParsePlaneInfoRequestFsinnPDU(t *testing.T) { }, { "Invalid From", - "#SB12345678:ATC:FSIPIR:0:UAL:A320:::::A20N:United Airbus A320neo\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#SB1234567812345678123456781234567812345678:ATC:FSIPIR:0:UAL:A320:::::A20N:United Airbus A320neo\r\n", + &PlaneInfoRequestFsinnPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid To", - "#SBPILOT:CONTROLLER123:FSIPIR:0:LUF:B77W:::::B77W:Lufthansa Boeing 777\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#SBPILOT:CONTROLLER123CONTROLLER123CONTROLLER123CONTROLLER123CONTROLLER123:FSIPIR:0:LUF:B77W:::::B77W:Lufthansa Boeing 777\r\n", + &PlaneInfoRequestFsinnPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Extra delimiter", - "#SBPILOT:ATC:FSIPIR:0:DLH:::A343:::A343:Lufthansa Airbus A340-300\r\n", - nil, - NewGenericFSDError(SyntaxError), - }, - { - "Missing ICAO code", - "#SBPILOT:ATC:FSIPIR:0::7378:::::B738:Ryanair Boeing 737-800\r\n", - nil, - NewGenericFSDError(SyntaxError), + "#SBPILOT:ATC:FSIPIR:0:DLH::::A343:::A343:Lufthansa Airbus A340-300\r\n", + &PlaneInfoRequestFsinnPDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Invalid PDU type", "#SBPILOT:ATC:FOOBAR:0:SWR:A333:::::A333:Swiss Airbus A330-300\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoRequestFsinnPDU{}, + NewGenericFSDError(SyntaxError, "FOOBAR", "third parameter must be 'FSIPIR'"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParsePlaneInfoRequestFsinnPDU(tc.packet) + pdu := PlaneInfoRequestFsinnPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/plane_info_request_test.go b/protocol/plane_info_request_test.go index 4f0f707..e826d16 100644 --- a/protocol/plane_info_request_test.go +++ b/protocol/plane_info_request_test.go @@ -1,6 +1,7 @@ package protocol import ( + "strings" "testing" "github.com/go-playground/validator/v10" @@ -28,61 +29,66 @@ func TestParsePlaneInfoRequestPDU(t *testing.T) { { name: "Last element not PIR", packet: "#SBPILOT:ATC:PI\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "PI", "third parameter must be 'PIR'"), }, { name: "Missing To field", packet: "#SBPILOT::PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing From field", packet: "#SB:ATC:PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Extra fields", packet: "#SBPILOT:ATC:EXTRA:PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { name: "Invalid From field (non-alphanumerical)", packet: "#SBP!@#$:ATC:PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid To field (too long)", - packet: "#SBPILOT:12345678:PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "#SBPILOT:1234567812345678123456781234567812345678:PIR\r\n", + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Input with incorrect prefix", packet: "$DIPILOT:ATC:PIR\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PlaneInfoRequestPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParsePlaneInfoRequestPDU(tc.packet) + pdu := PlaneInfoRequestPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/plane_info_response.go b/protocol/plane_info_response.go index 1aa0a5e..76826bc 100644 --- a/protocol/plane_info_response.go +++ b/protocol/plane_info_response.go @@ -6,12 +6,12 @@ import ( ) type PlaneInfoResponsePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` - Equipment string `validate:"required,max=32"` - Airline string `validate:"max=32"` - Livery string `validate:"max=32"` - CSL string `validate:"max=32"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` + Equipment string `validate:"required,max=64"` + Airline string `validate:"max=64"` + Livery string `validate:"max=64"` + CSL string `validate:"max=64"` } func (p *PlaneInfoResponsePDU) Serialize() string { @@ -26,63 +26,67 @@ func (p *PlaneInfoResponsePDU) Serialize() string { str += fmt.Sprintf(":CSL=%s", p.CSL) } - str += PacketDelimeter + str += PacketDelimiter return str } -func ParsePlaneInfoResponsePDU(rawPacket string) (*PlaneInfoResponsePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#SB") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) < 5 || len(fields) > 8 { - return nil, NewGenericFSDError(SyntaxError) +func (p *PlaneInfoResponsePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#SB") + + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) < 5 || len(fields) > 8 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } if fields[2] != "PI" || fields[3] != "GEN" { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[2], "third parameter must be 'PI'") } - var equipment, airline, livery, csl string + if fields[3] != "GEN" { + return NewGenericFSDError(SyntaxError, fields[3], "fourth parameter must be 'GEN'") + } + + pdu := PlaneInfoResponsePDU{ + From: fields[0], + To: fields[1], + } if !strings.HasPrefix(fields[4], "EQUIPMENT=") || len(fields[4]) < len("EQUIPMENT=")+1 { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[4], "invalid EQUIPMENT= field") } - equipment = strings.SplitN(fields[4], "=", 2)[1] + pdu.Equipment = strings.SplitN(fields[4], "=", 2)[1] if len(fields) > 5 { if !strings.HasPrefix(fields[5], "AIRLINE=") || len(fields[5]) < len("AIRLINE=")+1 { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[5], "invalid AIRLINE= field") } - airline = strings.SplitN(fields[5], "=", 2)[1] + pdu.Airline = strings.SplitN(fields[5], "=", 2)[1] } if len(fields) > 6 { if !strings.HasPrefix(fields[6], "LIVERY=") || len(fields[6]) < len("LIVERY=")+1 { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[6], "invalid LIVERY= field") } - livery = strings.SplitN(fields[6], "=", 2)[1] + pdu.Livery = strings.SplitN(fields[6], "=", 2)[1] } if len(fields) > 7 { if !strings.HasPrefix(fields[7], "CSL=") || len(fields[6]) < len("CSL=")+1 { - return nil, NewGenericFSDError(SyntaxError) + return NewGenericFSDError(SyntaxError, fields[7], "invalid CSL= field") } - csl = strings.SplitN(fields[7], "=", 2)[1] + pdu.CSL = strings.SplitN(fields[7], "=", 2)[1] } - pdu := &PlaneInfoResponsePDU{ - From: fields[0], - To: fields[1], - Equipment: equipment, - Airline: airline, - Livery: livery, - CSL: csl, + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } + // Copy new pdu into receiver + *p = pdu - return pdu, nil + return nil } diff --git a/protocol/plane_info_response_test.go b/protocol/plane_info_response_test.go index 594929c..4148cb0 100644 --- a/protocol/plane_info_response_test.go +++ b/protocol/plane_info_response_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -44,56 +45,60 @@ func TestParsePlaneInfoResponsePDU(t *testing.T) { { "Invalid - Missing EQUIPMENT Prefix", "#SBATC:PILOT:PI:GEN:A320:LIVERY=Standard\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "A320", "invalid EQUIPMENT= field"), }, { "Invalid - Wrong HEADER Prefix", "$SBATC:PILOT:PI:GEN:EQUIPMENT=A320\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid - Field Count Less", "#SBATC:PILOT:PI:GEN\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Invalid - Field Count More", "#SBATC:PILOT:PI:GEN:EQUIPMENT=A320:AIRLINE=Delta:LIVERY=Standard:CSL=ModelABC:ExtraField\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { "Invalid - No From Field", "#SB:PILOT:PI:GEN:EQUIPMENT=A320\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Invalid - No To Field", "#SBATC::PI:GEN:EQUIPMENT=A320\r\n", - nil, - NewGenericFSDError(SyntaxError), + &PlaneInfoResponsePDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParsePlaneInfoResponsePDU(tc.packet) + pdu := PlaneInfoResponsePDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.Error(t, err) - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/pong.go b/protocol/pong.go index e1ca45f..be41624 100644 --- a/protocol/pong.go +++ b/protocol/pong.go @@ -6,22 +6,22 @@ import ( ) type PongPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` - Timestamp string `validate:"required,max=32"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` + Timestamp string `validate:"required,max=64"` } func (p *PongPDU) Serialize() string { - return fmt.Sprintf("$PO%s:%s:%s%s", p.From, p.To, p.Timestamp, PacketDelimeter) + return fmt.Sprintf("$PO%s:%s:%s%s", p.From, p.To, p.Timestamp, PacketDelimiter) } -func ParsePongPDU(rawPacket string) (*PongPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$PO") - fields := strings.Split(rawPacket, Delimeter) +func (p *PongPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$PO") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := PongPDU{ @@ -30,10 +30,15 @@ func ParsePongPDU(rawPacket string) (*PongPDU, error) { Timestamp: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/pong_test.go b/protocol/pong_test.go index a2a71dd..c855e81 100644 --- a/protocol/pong_test.go +++ b/protocol/pong_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -52,40 +53,45 @@ func TestParsePongPDU(t *testing.T) { { name: "Missing fields", packet: "$POSOURCE:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &PongPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, { name: "Exceeds max field length", - packet: "$POSOURCESOURCE:TARGETTARGET:16094592000000000000000000000000\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$POSOURCESOURCESOURCESOURCESOURCESOURCE:TARGETTARGETTARGETTARGETTARGETTARGETTARGETTARGET:16094592000000000000000000000000\r\n", + want: &PongPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid From field", - packet: "$PO12345678:TARGET:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$PO1234567812345678123456781234567812345678:TARGET:1609459200\r\n", + want: &PongPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid To field", - packet: "$POSOURCE:12345678:1609459200\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "$POSOURCE:123456781234567812345678123456781234567812345678:1609459200\r\n", + want: &PongPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := ParsePongPDU(tc.packet) + pdu := PongPDU{} + err := pdu.Parse(tc.packet) if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/send_fast.go b/protocol/send_fast.go index 8930947..2fed119 100644 --- a/protocol/send_fast.go +++ b/protocol/send_fast.go @@ -7,8 +7,8 @@ import ( ) type SendFastPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` DoSendFast bool `validate:""` } @@ -17,34 +17,44 @@ func (p *SendFastPDU) Serialize() string { if p.DoSendFast { doSendFastInt = 1 } - return fmt.Sprintf("$SF%s:%s:%d%s", p.From, p.To, doSendFastInt, PacketDelimeter) + return fmt.Sprintf("$SF%s:%s:%d%s", p.From, p.To, doSendFastInt, PacketDelimiter) } -func ParseSendFastPDU(rawPacket string) (*SendFastPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$SF") - fields := strings.Split(rawPacket, Delimeter) +func (p *SendFastPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$SF") - if len(fields) != 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } - doSendFastInt, err := strconv.Atoi(fields[2]) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) - } - doSendFast := doSendFastInt == 1 - pdu := SendFastPDU{ - From: fields[0], - To: fields[1], - DoSendFast: doSendFast, + From: fields[0], + To: fields[1], } - err = V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + var doSendFastInt int + var err error + if doSendFastInt, err = strconv.Atoi(fields[2]); err != nil { + return NewGenericFSDError(SyntaxError, fields[2], "invalid send fast integer") } - return &pdu, nil + if doSendFastInt < 0 || doSendFastInt > 1 { + return NewGenericFSDError(SyntaxError, fields[2], "send fast integer must be 1 or 0") + } + + pdu.DoSendFast = doSendFastInt == 1 + + if err = V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err + } + + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/send_fast_test.go b/protocol/send_fast_test.go index 6139806..0951a7b 100644 --- a/protocol/send_fast_test.go +++ b/protocol/send_fast_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -52,7 +53,7 @@ func TestParseSendFastPDU(t *testing.T) { name string packet string want *SendFastPDU - wantErr bool + wantErr error }{ { name: "Valid packet with DoSendFast true", @@ -62,7 +63,7 @@ func TestParseSendFastPDU(t *testing.T) { To: "CLIENT", DoSendFast: true, }, - wantErr: false, + wantErr: nil, }, { name: "Valid packet with DoSendFast false", @@ -72,42 +73,52 @@ func TestParseSendFastPDU(t *testing.T) { To: "CLIENT", DoSendFast: false, }, - wantErr: false, + wantErr: nil, }, { name: "Packet with missing DoSendFast field", packet: "$SFSERVER:CLIENT:\r\n", - want: nil, - wantErr: true, + want: &SendFastPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid send fast integer"), }, { name: "Packet with invalid DoSendFast field", packet: "$SFSERVER:CLIENT:not_a_number\r\n", - want: nil, - wantErr: true, + want: &SendFastPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "not_a_number", "invalid send fast integer"), + }, + { + name: "out of bounds send fast integer", + packet: "$SFSERVER:CLIENT:2\r\n", + want: &SendFastPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "2", "send fast integer must be 1 or 0"), }, { name: "Incorrect packet format", packet: "SFSERVERCLIENT0\r\n", - want: nil, - wantErr: true, + want: &SendFastPDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "invalid parameter count"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseSendFastPDU(tc.packet) + pdu := SendFastPDU{} + err := pdu.Parse(tc.packet) - // Check the error - if tc.wantErr { - assert.Error(t, err) + if tc.wantErr != nil { + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/protocol/server_identification.go b/protocol/server_identification.go index 7a6925f..946242f 100644 --- a/protocol/server_identification.go +++ b/protocol/server_identification.go @@ -6,35 +6,41 @@ import ( ) type ServerIdentificationPDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,alphanum,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,alphanum,max=16"` Version string `validate:"required,max=32"` InitialChallenge string `validate:"required,hexadecimal,max=32"` } func (p *ServerIdentificationPDU) Serialize() string { - return fmt.Sprintf("$DI%s:%s:%s:%s%s", p.From, p.To, p.Version, p.InitialChallenge, PacketDelimeter) + return fmt.Sprintf("$DI%s:%s:%s:%s%s", p.From, p.To, p.Version, p.InitialChallenge, PacketDelimiter) } -func ParseServerIdentificationPDU(rawPacket string) (*ServerIdentificationPDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "$DI") - fields := strings.Split(rawPacket, Delimeter) - if len(fields) != 4 { - return nil, NewGenericFSDError(SyntaxError) +func (p *ServerIdentificationPDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "$DI") + + var fields []string + if fields = strings.Split(packet, Delimiter); len(fields) != 4 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } - pdu := &ServerIdentificationPDU{ + pdu := ServerIdentificationPDU{ From: fields[0], To: fields[1], Version: fields[2], InitialChallenge: fields[3], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/server_identification_test.go b/protocol/server_identification_test.go index 5786213..f926434 100644 --- a/protocol/server_identification_test.go +++ b/protocol/server_identification_test.go @@ -3,6 +3,7 @@ package protocol import ( "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -17,49 +18,54 @@ func TestServerIdentificationPDU_Serialize2(t *testing.T) { }{ { "Valid", - "$DISERVER:CLIENT:fsd server:0123456789abcdef\r\n", + "$DISERVER:CLIENT:server server:0123456789abcdef\r\n", &ServerIdentificationPDU{ From: "SERVER", To: "CLIENT", - Version: "fsd server", + Version: "server server", InitialChallenge: "0123456789abcdef", }, nil, }, { "Missing field", - "$DI:CLIENT:fsd server:0123456789abcdef\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$DI:CLIENT:server server:0123456789abcdef\r\n", + &ServerIdentificationPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Non-hexadecimal challenge", - "$DISERVER:CLIENT:fsd server:ghijklmnop\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$DISERVER:CLIENT:server server:ghijklmnop\r\n", + &ServerIdentificationPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, { "Challenge too long", - "$DISERVER:CLIENT:fsd server:fd9bb85563fc21920f352a74a0917ea88\r\n", - nil, - NewGenericFSDError(SyntaxError), + "$DISERVER:CLIENT:server server:fd9bb85563fc21920f352a74a0917ea88\r\n", + &ServerIdentificationPDU{}, + NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseServerIdentificationPDU(tc.packet) + pdu := ServerIdentificationPDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } @@ -69,11 +75,11 @@ func TestServerIdentificationPDU_Serialize(t *testing.T) { pdu := ServerIdentificationPDU{ From: "SERVER", To: "CLIENT", - Version: "fsd server", + Version: "server server", InitialChallenge: "12345", } s := pdu.Serialize() - assert.Equal(t, "$DISERVER:CLIENT:fsd server:12345\r\n", s) + assert.Equal(t, "$DISERVER:CLIENT:server server:12345\r\n", s) } } diff --git a/protocol/text_message.go b/protocol/text_message.go index 10983f9..5a80391 100644 --- a/protocol/text_message.go +++ b/protocol/text_message.go @@ -6,22 +6,22 @@ import ( ) type TextMessagePDU struct { - From string `validate:"required,alphanum,max=7"` - To string `validate:"required,max=7"` + From string `validate:"required,alphanum,max=16"` + To string `validate:"required,max=16"` Message string `validate:"required"` } func (p *TextMessagePDU) Serialize() string { - return fmt.Sprintf("#TM%s:%s:%s%s", p.From, p.To, p.Message, PacketDelimeter) + return fmt.Sprintf("#TM%s:%s:%s%s", p.From, p.To, p.Message, PacketDelimiter) } -func ParseTextMessagePDU(rawPacket string) (*TextMessagePDU, error) { - rawPacket = strings.TrimSuffix(rawPacket, PacketDelimeter) - rawPacket = strings.TrimPrefix(rawPacket, "#TM") - fields := strings.SplitN(rawPacket, Delimeter, 3) +func (p *TextMessagePDU) Parse(packet string) error { + packet = strings.TrimSuffix(packet, PacketDelimiter) + packet = strings.TrimPrefix(packet, "#TM") - if len(fields) < 3 { - return nil, NewGenericFSDError(SyntaxError) + var fields []string + if fields = strings.SplitN(packet, Delimiter, 3); len(fields) < 3 { + return NewGenericFSDError(SyntaxError, "", "invalid parameter count") } pdu := TextMessagePDU{ @@ -30,10 +30,15 @@ func ParseTextMessagePDU(rawPacket string) (*TextMessagePDU, error) { Message: fields[2], } - err := V.Struct(pdu) - if err != nil { - return nil, NewGenericFSDError(SyntaxError) + if err := V.Struct(pdu); err != nil { + if validatorErr := getFSDErrorFromValidatorErrors(err); err != nil { + return validatorErr + } + return err } - return &pdu, nil + // Copy new pdu into receiver + *p = pdu + + return nil } diff --git a/protocol/text_message_test.go b/protocol/text_message_test.go index 386b7e0..5103d43 100644 --- a/protocol/text_message_test.go +++ b/protocol/text_message_test.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "strings" "testing" ) @@ -28,44 +29,49 @@ func TestParseTextMessagePDU(t *testing.T) { }, { name: "Invalid from field", - packet: "#TMJOHN99999:DOE:Hello, world!\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "#TMJOHN99999JOHN99999JOHN99999JOHN99999:DOE:Hello, world!\r\n", + want: &TextMessagePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Invalid to field", - packet: "#TMJOHN:DOE1234567:Hello, world!\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + packet: "#TMJOHN:DOE1234567DOE1234567DOE1234567DOE1234567:Hello, world!\r\n", + want: &TextMessagePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing to field", packet: "#TMJOHN::Hello, world!\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &TextMessagePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, { name: "Missing message", packet: "#TMJOHN:DOE:\r\n", - want: nil, - wantErr: NewGenericFSDError(SyntaxError), + want: &TextMessagePDU{}, + wantErr: NewGenericFSDError(SyntaxError, "", "validation error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Perform the parsing - result, err := ParseTextMessagePDU(tc.packet) + pdu := TextMessagePDU{} + err := pdu.Parse(tc.packet) // Check the error if tc.wantErr != nil { - assert.EqualError(t, err, tc.wantErr.Error()) + if strings.Contains(tc.wantErr.Error(), "validation error") { + assert.Contains(t, err.Error(), "validation error") + } else { + assert.EqualError(t, err, tc.wantErr.Error()) + } } else { assert.NoError(t, err) } // Verify the result - assert.Equal(t, tc.want, result) + assert.Equal(t, tc.want, &pdu) }) } } diff --git a/vatsimauth/vatsimauth.go b/protocol/vatsimauth/vatsimauth.go similarity index 77% rename from vatsimauth/vatsimauth.go rename to protocol/vatsimauth/vatsimauth.go index 3dc49ad..07687af 100644 --- a/vatsimauth/vatsimauth.go +++ b/protocol/vatsimauth/vatsimauth.go @@ -4,6 +4,8 @@ import ( "crypto/md5" "crypto/rand" "encoding/hex" + "io" + "strings" ) var Keys = map[uint16]string{ @@ -46,17 +48,31 @@ func (v *VatsimAuth) GenerateResponse(challenge string) string { s1, s2, s3 := v.state[0:12], v.state[12:22], v.state[22:32] - h := "" + h := strings.Builder{} + h.Grow(len(s1) + len(s2) + len(s3) + len(c1) + len(c2)) + switch v.clientID % 3 { case 0: - h = s1 + c1 + s2 + c2 + s3 + h.WriteString(s1) + h.WriteString(c1) + h.WriteString(s2) + h.WriteString(c2) + h.WriteString(s3) case 1: - h = s2 + c1 + s3 + c2 + s1 + h.WriteString(s2) + h.WriteString(c1) + h.WriteString(s3) + h.WriteString(c2) + h.WriteString(s1) default: - h = s3 + c1 + s1 + c2 + s2 + h.WriteString(s3) + h.WriteString(c1) + h.WriteString(s1) + h.WriteString(c2) + h.WriteString(s2) } - hash := md5.Sum([]byte(h)) + hash := md5.Sum([]byte(h.String())) return hex.EncodeToString(hash[:]) } @@ -68,9 +84,8 @@ func (v *VatsimAuth) UpdateState(hash string) { // GenerateChallenge returns a cryptographically secure random challenge string func GenerateChallenge() (string, error) { - challenge := make([]byte, 4) - _, err := rand.Read(challenge) - if err != nil { + challenge := make([]byte, 8) + if _, err := io.ReadFull(rand.Reader, challenge); err != nil { return "", err } diff --git a/vatsimauth/vatsimauth_test.go b/protocol/vatsimauth/vatsimauth_test.go similarity index 92% rename from vatsimauth/vatsimauth_test.go rename to protocol/vatsimauth/vatsimauth_test.go index 024d1cf..0356c64 100644 --- a/vatsimauth/vatsimauth_test.go +++ b/protocol/vatsimauth/vatsimauth_test.go @@ -8,7 +8,7 @@ import ( func TestVatsimAuth(t *testing.T) { // vPilot clientID and key - v := NewVatsimAuth(35044, "fe28334fb753cf0e3d19942197b9ce3e") + v := NewVatsimAuth(35044, Keys[35044]) assert.NotNil(t, v) // Initial challenge @@ -32,5 +32,4 @@ func TestVatsimAuth(t *testing.T) { // $ZRN12345:SERVER:8953f545c4e0ffd20943ad89b8ddd087 assert.Equal(t, "8953f545c4e0ffd20943ad89b8ddd087", res) v.UpdateState(res) - } diff --git a/servercontext/servercontext.go b/servercontext/servercontext.go new file mode 100644 index 0000000..e62cdb5 --- /dev/null +++ b/servercontext/servercontext.go @@ -0,0 +1,194 @@ +package servercontext + +import ( + "context" + "crypto/rand" + "database/sql" + "github.com/go-playground/validator/v10" + "github.com/go-sql-driver/mysql" + _ "github.com/go-sql-driver/mysql" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/datafeed" + "github.com/renorris/openfsd/postoffice" + "github.com/renorris/openfsd/protocol" + "github.com/sethvargo/go-envconfig" + "io" + "log" + "os" + "time" +) + +const VersionIdentifier = "openfsd v0.1-alpha" + +const inMemoryDatabaseAddress = "tcp(127.0.0.1:33060)" + +// serverContextSingleton is the main server context singleton +var serverContextSingleton *ServerContext + +func InitializeServerContextSingleton(ctx *ServerContext) { + serverContextSingleton = ctx +} + +// Config returns the server configuration +func Config() *ServerConfig { + return &serverContextSingleton.config +} + +// PostOffice returns the server post office singleton +func PostOffice() *postoffice.PostOffice { + return serverContextSingleton.postOffice +} + +// JWTKey returns the server JWT private key +func JWTKey() []byte { + return serverContextSingleton.jwtKey +} + +// DB returns the server database singleton +func DB() *sql.DB { + return serverContextSingleton.db +} + +func DataFeed() *datafeed.DataFeed { + return serverContextSingleton.dataFeed +} + +type ServerConfig struct { + FSDListenAddress string `env:"FSD_ADDR, default=0.0.0.0:6809"` // FSD network frontend/port + HTTPListenAddress string `env:"HTTP_ADDR, default=0.0.0.0:8080"` // HTTP network frontend/port + TLSCertFile string `env:"TLS_CERT_FILE"` // TLS certificate file path + TLSKeyFile string `env:"TLS_KEY_FILE"` // TLS key file path + MySQLUser string `env:"MYSQL_USER"` // MySQL username + MySQLPass string `env:"MYSQL_PASS"` // MySQL password + MySQLNet string `env:"MYSQL_NET"` // MySQL network protocol e.g. tcp, unix, etc + MySQLAddr string `env:"MYSQL_ADDR"` // MySQL network address e.g. 127.0.0.1:3306 + MySQLDBName string `env:"MYSQL_DBNAME"` // MySQL database name + InMemoryDB bool `env:"IN_MEMORY_DB, default=false"` // Whether to use an ephemeral in-memory DB instead of a real MySQL server + MOTD string `env:"MOTD, default=openfsd"` // Server "Message of the Day" + PlaintextPasswords bool `env:"PLAINTEXT_PASSWORDS, default=false"` // Whether to enable plaintext FSD passwords +} + +type ServerContext struct { + config ServerConfig + postOffice *postoffice.PostOffice + jwtKey []byte + db *sql.DB + dataFeed *datafeed.DataFeed +} + +const privateKeyFile = "./jwtprivatekey" + +// New creates a new ServerContext. +// Panics on failure. +func New() *ServerContext { + server := ServerContext{} + + // PostOffice is ready zero-initialized. + server.postOffice = postoffice.NewPostOffice() + + // Parse config environment variables + ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second) + if err := envconfig.Process(ctx, &server.config); err != nil { + log.Fatal(err) + } + cancelCtx() + + if server.config.InMemoryDB { + server.config.MySQLAddr = "127.0.0.1:33060" + server.config.MySQLNet = "tcp" + server.config.MySQLDBName = "openfsd" + + server.config.MySQLUser = "" + server.config.MySQLPass = "" + } + + // Load the JWT private key + server.jwtKey = loadOrCreateJWTKey(privateKeyFile) + + // Instantiate protocol validator + protocol.V = validator.New(validator.WithRequiredStructEnabled()) + + // Create SQL db + cfg := mysql.Config{ + User: server.config.MySQLUser, + Passwd: server.config.MySQLPass, + Net: server.config.MySQLNet, + Addr: server.config.MySQLAddr, + DBName: server.config.MySQLDBName, + Params: map[string]string{"parseTime": "true"}, + } + + if server.config.InMemoryDB { + cfg.AllowNativePasswords = true + } + + var db *sql.DB + var err error + if db, err = sql.Open("mysql", cfg.FormatDSN()); err != nil { + log.Fatal(err) + } + server.db = db + + if server.config.InMemoryDB { + server.db.SetMaxOpenConns(1) + } else { + if err = database.Initialize(server.db); err != nil { + log.Fatal(err) + } + } + + // Initialize data feed + server.dataFeed = &datafeed.DataFeed{} + + return &server +} + +// loadOrCreateJWTKey loads or creates the JWT key contained in the file `filePath`. +// Panics on error. +func loadOrCreateJWTKey(filePath string) (key []byte) { + // Load JWT key file + var jwtKeyFile *os.File + var err error + if jwtKeyFile, err = os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0600); err != nil { + log.Fatal(err) + } + + // Check if the file is empty + var ret int64 + if ret, err = jwtKeyFile.Seek(0, io.SeekEnd); ret != 64 { + // Seeked to end of file and the length wasn't equal to the expected key length: 64 bytes. + // Assume the keyfile is empty and needs to be written. + + // Truncate + if err = jwtKeyFile.Truncate(0); err != nil { + log.Fatal(err) + } + + // Seek to start + if _, err = jwtKeyFile.Seek(0, io.SeekStart); err != nil { + log.Fatal(err) + } + + // Copy 64 random bytes into the file + if _, err = io.CopyN(jwtKeyFile, rand.Reader, 64); err != nil { + log.Fatal(err) + } + } + + // Seek back to the beginning + if _, err = jwtKeyFile.Seek(0, io.SeekStart); err != nil { + log.Fatal(err) + } + + // Read the entirety of the jwt key file + if key, err = io.ReadAll(jwtKeyFile); err != nil { + log.Fatal(err) + } + + // Close it + if err = jwtKeyFile.Close(); err != nil { + log.Fatal(err) + } + + return +} diff --git a/fsd_client_logic_test.go b/test/fsd_client_logic_test.go similarity index 84% rename from fsd_client_logic_test.go rename to test/fsd_client_logic_test.go index 5b1e836..5a9204a 100644 --- a/fsd_client_logic_test.go +++ b/test/fsd_client_logic_test.go @@ -1,12 +1,22 @@ -package main +package test import ( "bufio" + "bytes" "context" + "encoding/json" + "errors" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/bootstrap" + "github.com/renorris/openfsd/database" "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" "github.com/stretchr/testify/assert" + "io" "net" + "net/http" "os" + "strconv" "strings" "sync" "testing" @@ -28,47 +38,71 @@ type clientStruct struct { simulatorType int realName string - preliminaryTestPackets []protocol.PDU // Packets to send after logging in, but before the next client logs in - preliminaryWantPackets []protocol.PDU // Expected packets to receive before the next client logs in + preliminaryTestPackets []protocol.PDU // Packets to send after logging in, but before the next browser logs in + preliminaryWantPackets []protocol.PDU // Expected packets to receive before the next browser logs in testPackets []protocol.PDU // Packets to send in normal post-login state wantPackets []protocol.PDU // Expected packets to receive } // TestFSDClientLogic focuses on post-login logic func TestFSDClientLogic(t *testing.T) { - SC = &ServerConfig{ - FsdListenAddr: "localhost:6809", - HttpListenAddr: "localhost:9086", - HttpsEnabled: false, - DatabaseFile: "./test.db", - MOTD: "openfsd", + if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil { + t.Fatal(err) } - // Delete any existing database file so a new one is created - os.Remove(SC.DatabaseFile) - defer os.Remove(SC.DatabaseFile) + // Start the server + ctx, cancelCtx := context.WithCancel(context.Background()) + b := bootstrap.NewDefaultBootstrap() + if err := b.Start(ctx); err != nil { + t.Fatal(err) + } - // Run configuration helpers and start the FSD server - configureJwt() - configurePostOffice() - configureProtocolValidator() - configureDatabase() + // Add test user + user1 := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test User 1", + LastName: "Test User 1 Lastname", + Password: "54321", + FSDPassword: "12345", + NetworkRating: protocol.NetworkRatingOBS, + PilotRating: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } - // Add test users - addUserToDatabase(t, 1000000, "12345", protocol.NetworkRatingOBS) - addUserToDatabase(t, 1000001, "12345", protocol.NetworkRatingOBS) - addUserToDatabase(t, 1000002, "12345", protocol.NetworkRatingSUP) + var err error + user1.CID, err = user1.Insert(servercontext.DB()) + assert.Nil(t, err) - // Start FSD server - fsdCtx, cancelFsd := context.WithCancel(context.Background()) - go StartFSDServer(fsdCtx) - defer cancelFsd() + user2 := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test User 2", + LastName: "Test User 2 Lastname", + Password: "54321", + FSDPassword: "12345", + NetworkRating: protocol.NetworkRatingOBS, + PilotRating: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } - // Start http server - httpCtx, cancelHttp := context.WithCancel(context.Background()) - go StartHttpServer(httpCtx) - defer cancelHttp() - time.Sleep(50 * time.Millisecond) + user2.CID, err = user2.Insert(servercontext.DB()) + assert.Nil(t, err) + + user3 := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test User 3", + LastName: "Test User 3 Lastname", + Password: "54321", + FSDPassword: "12345", + NetworkRating: protocol.NetworkRatingSUP, + PilotRating: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + user3.CID, err = user3.Insert(servercontext.DB()) + assert.Nil(t, err) tests := []struct { testName string @@ -79,7 +113,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -116,7 +150,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -155,7 +189,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -202,7 +236,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: 1, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -265,7 +299,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -380,7 +414,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -416,7 +450,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: 1, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -442,7 +476,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -482,7 +516,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -502,7 +536,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: 1, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -519,7 +553,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -550,7 +584,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -569,7 +603,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: 1, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -586,7 +620,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -618,7 +652,7 @@ func TestFSDClientLogic(t *testing.T) { clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -651,11 +685,11 @@ func TestFSDClientLogic(t *testing.T) { }, }, { - testName: "Kill ($!!)", + testName: "kill ($!!)", clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -675,7 +709,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "SUP", To: protocol.ServerCallsign, - CID: 1000002, + CID: user3.CID, Token: "", NetworkRating: protocol.NetworkRatingSUP, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -691,7 +725,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "SUP", - cid: 1000002, + cid: user3.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -718,11 +752,11 @@ func TestFSDClientLogic(t *testing.T) { }, }, { - testName: "Kill not allowed", + testName: "kill not allowed", clients: []clientStruct{ { callsign: "N123", - cid: 1000000, + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -742,7 +776,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: protocol.NetworkRatingOBS, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -758,7 +792,7 @@ func TestFSDClientLogic(t *testing.T) { }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -793,8 +827,8 @@ func TestFSDClientLogic(t *testing.T) { testName: "Delete pilot broadcast", clients: []clientStruct{ { - callsign: "N123", - cid: 1000000, + callsign: "DEL", + cid: user1.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -814,7 +848,7 @@ func TestFSDClientLogic(t *testing.T) { &protocol.AddPilotPDU{ From: "N124", To: protocol.ServerCallsign, - CID: 1000001, + CID: user2.CID, Token: "", NetworkRating: protocol.NetworkRatingOBS, ProtocolRevision: protocol.ProtoRevisionVatsim2022, @@ -823,13 +857,13 @@ func TestFSDClientLogic(t *testing.T) { }, &protocol.DeletePilotPDU{ From: "N124", - CID: 1000001, + CID: user2.CID, }, }, }, { callsign: "N124", - cid: 1000001, + cid: user2.CID, password: "12345", clientID: 35044, clientName: "vPilot", @@ -847,7 +881,7 @@ func TestFSDClientLogic(t *testing.T) { testPackets: []protocol.PDU{ &protocol.DeletePilotPDU{ From: "N124", - CID: 1000001, + CID: user2.CID, }, }, wantPackets: []protocol.PDU{}, @@ -860,7 +894,7 @@ func TestFSDClientLogic(t *testing.T) { for _, tc := range tests { t.Run(tc.testName, func(t *testing.T) { var doneWg sync.WaitGroup - // Spawn each client + // Spawn each browser for _, client := range tc.clients { c := client doneWg.Add(1) @@ -871,12 +905,15 @@ func TestFSDClientLogic(t *testing.T) { go func() { defer doneWg.Done() - // Log in the client. - // Test cases are meant to be executed after the client has logged in. - // Get a JWT token first - jwtResponse := doJwtRequest(t, "http://localhost:9086/api/fsd-jwt", c.cid, c.password) + // Log in the browser. + // Test cases are meant to be executed after the browser has logged in. + // Load a JWT token first + var token string + var err error + token, err = getJWTToken(c.cid, c.password) + assert.Nil(t, err) - conn, err := net.Dial("tcp4", SC.FsdListenAddr) + conn, err := net.Dial("tcp4", servercontext.Config().FSDListenAddress) assert.Nil(t, err) err = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) @@ -908,8 +945,8 @@ func TestFSDClientLogic(t *testing.T) { From: c.callsign, To: protocol.ServerCallsign, CID: c.cid, - Token: jwtResponse.Token, - NetworkRating: c.networkRating, + Token: token, + NetworkRating: protocol.NetworkRating(c.networkRating), ProtocolRevision: c.protocolRevsion, SimulatorType: c.simulatorType, RealName: c.realName, @@ -924,13 +961,14 @@ func TestFSDClientLogic(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, motdMsg) - motdReceivedPDU, err := protocol.ParseTextMessagePDU(motdMsg) + motdReceivedPDU := protocol.TextMessagePDU{} + err = motdReceivedPDU.Parse(motdMsg) assert.Nil(t, err) expectedMOTD := protocol.TextMessagePDU{ From: protocol.ServerCallsign, To: c.callsign, - Message: SC.MOTD, + Message: servercontext.Config().MOTD, } assert.Equal(t, expectedMOTD.Serialize(), motdReceivedPDU.Serialize()) @@ -943,7 +981,7 @@ func TestFSDClientLogic(t *testing.T) { // Verify post-login returned packets are correct for _, packet := range c.preliminaryWantPackets { - deadlineErr := conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + deadlineErr := conn.SetReadDeadline(time.Now().Add(30 * time.Second)) assert.Nil(t, deadlineErr) recvPacket, recvErr := reader.ReadString('\n') @@ -963,7 +1001,7 @@ func TestFSDClientLogic(t *testing.T) { // Verify returned packets are correct for _, packet := range c.wantPackets { - deadlineErr := conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + deadlineErr := conn.SetReadDeadline(time.Now().Add(30 * time.Second)) assert.Nil(t, deadlineErr) recvPacket, recvErr := reader.ReadString('\n') @@ -981,4 +1019,54 @@ func TestFSDClientLogic(t *testing.T) { doneWg.Wait() }) } + + // Close context + cancelCtx() + + // Wait for bootstrap done + <-b.Done +} + +func getJWTToken(cid int, password string) (token string, err error) { + reqPayload := auth.FSDJWTRequest{ + CID: strconv.Itoa(cid), + Password: password, + } + + var reqPayloadBytes []byte + if reqPayloadBytes, err = json.Marshal(reqPayload); err != nil { + return + } + + var addr *net.TCPAddr + if addr, err = net.ResolveTCPAddr("tcp4", servercontext.Config().HTTPListenAddress); err != nil { + return + } + + var resp *http.Response + if resp, err = http.Post("http://localhost:"+strconv.Itoa(addr.Port)+"/api/v1/fsd-jwt", "application/json", bytes.NewReader(reqPayloadBytes)); err != nil { + return + } + + if resp.StatusCode != 200 { + err = errors.New("HTTP status " + resp.Status) + return + } + + var respBody []byte + if respBody, err = io.ReadAll(resp.Body); err != nil { + return + } + + var respPayload auth.FSDJWTResponse + if err = json.Unmarshal(respBody, &respPayload); err != nil { + return + } + + if !respPayload.Success { + err = errors.New("response payload unsuccessful " + respPayload.ErrorMsg) + return + } + + return respPayload.Token, nil } diff --git a/fsd_client_login_test.go b/test/fsd_client_login_test.go similarity index 81% rename from fsd_client_login_test.go rename to test/fsd_client_login_test.go index 7b2213c..d351c6f 100644 --- a/fsd_client_login_test.go +++ b/test/fsd_client_login_test.go @@ -1,69 +1,57 @@ -package main +package test import ( "bufio" "context" "errors" "github.com/golang-jwt/jwt/v5" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/bootstrap" + "github.com/renorris/openfsd/database" "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" "github.com/stretchr/testify/assert" - "golang.org/x/crypto/bcrypt" + "io" "net" "os" + "strconv" "strings" - "syscall" "testing" "time" ) -func addUserToDatabase(t *testing.T, cid int, password string, rating int) { - bcryptBytes, err := bcrypt.GenerateFromPassword([]byte(password), 4) - assert.Nil(t, err) - passwordHash := string(bcryptBytes) - - _, err = AddUserRecord(DB, cid, passwordHash, rating, "Test User") - assert.Nil(t, err) -} - func TestFSDClientLogin(t *testing.T) { // Setup config for testing environment - SC = &ServerConfig{ - FsdListenAddr: "localhost:6809", - HttpListenAddr: "localhost:9086", - HttpsEnabled: false, - DatabaseFile: "./test.db", - MOTD: "motd line 1\nmotd line 2", - PlaintextPasswords: false, + if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil { + t.Fatal(err) } - // Delete any existing database file so a new one is created - os.Remove(SC.DatabaseFile) - defer os.Remove(SC.DatabaseFile) + // Start the server + ctx, cancelCtx := context.WithCancel(context.Background()) + b := bootstrap.NewDefaultBootstrap() + err := b.Start(ctx) + assert.Nil(t, err) - // Run configuration helpers - configureDatabase() - configureJwt() - configurePostOffice() - configureProtocolValidator() + // Add test user + user1 := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test User 1", + LastName: "Test User 1 Lastname", + Password: "54321", + FSDPassword: "12345", + NetworkRating: protocol.NetworkRatingOBS, + PilotRating: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } - // Start FSD listener - fsdCtx, cancelFsd := context.WithCancel(context.Background()) - defer cancelFsd() - go StartFSDServer(fsdCtx) - - // Start http server - httpCtx, cancelHttp := context.WithCancel(context.Background()) - defer cancelHttp() - go StartHttpServer(httpCtx) - time.Sleep(50 * time.Millisecond) - - addUserToDatabase(t, 1000000, "12345", 1) + user1.CID, err = user1.Insert(servercontext.DB()) + assert.Nil(t, err) // Simulate a jwt login token request - jwtResponse := doJwtRequest(t, "http://localhost:9086/api/fsd-jwt", 1000000, "12345") - assert.True(t, jwtResponse.Success) - assert.NotEmpty(t, jwtResponse.Token) - assert.Empty(t, jwtResponse.ErrorMsg) + var token string + token, err = getJWTToken(user1.CID, user1.FSDPassword) + assert.Nil(t, err) // Test successful FSD login process { @@ -86,7 +74,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -94,8 +82,8 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, - Token: jwtResponse.Token, + CID: user1.CID, + Token: token, NetworkRating: 1, ProtocolRevision: 101, SimulatorType: 2, @@ -114,7 +102,7 @@ func TestFSDClientLogin(t *testing.T) { expectedPDU := protocol.TextMessagePDU{ From: protocol.ServerCallsign, To: "N123", - Message: strings.Split(SC.MOTD, "\n")[0], + Message: strings.Split(servercontext.Config().MOTD, "\n")[0], } assert.Equal(t, expectedPDU.Serialize(), responseMsg) @@ -142,7 +130,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -150,8 +138,8 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, - Token: jwtResponse.Token, + CID: user1.CID, + Token: token, NetworkRating: 2, ProtocolRevision: 101, SimulatorType: 2, @@ -167,7 +155,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - assert.Equal(t, protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError).Serialize(), responseMsg) + assert.Equal(t, protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, "1", "try again at or below your assigned rating").Serialize(), responseMsg) conn.Close() } @@ -192,7 +180,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -200,8 +188,8 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, - Token: jwtResponse.Token, + CID: user1.CID, + Token: token, NetworkRating: 1, ProtocolRevision: 100, SimulatorType: 2, @@ -217,7 +205,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - assert.Equal(t, protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError).Serialize(), responseMsg) + assert.Equal(t, protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError, "100", "please connect with a client that supports the Vatsim2022 (101) protocol revision").Serialize(), responseMsg) conn.Close() } @@ -252,7 +240,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -260,8 +248,8 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, - Token: jwtResponse.Token, + CID: user1.CID, + Token: token, NetworkRating: 1, ProtocolRevision: 101, SimulatorType: 2, @@ -291,11 +279,11 @@ func TestFSDClientLogin(t *testing.T) { expectedPDU1 := protocol.TextMessagePDU{ From: protocol.ServerCallsign, To: "N123", - Message: strings.Split(SC.MOTD, "\n")[0], + Message: strings.Split(servercontext.Config().MOTD, "\n")[0], } assert.Equal(t, expectedPDU1.Serialize(), responseMsg1) - assert.Equal(t, protocol.NewGenericFSDError(protocol.CallsignInUseError).Serialize(), responseMsg2) + assert.Equal(t, protocol.NewGenericFSDError(protocol.CallsignInUseError, "N123", "").Serialize(), responseMsg2) conn1.Close() conn2.Close() @@ -315,8 +303,8 @@ func TestFSDClientLogin(t *testing.T) { assert.NotEmpty(t, serverIdent) assert.True(t, strings.HasPrefix(serverIdent, "$DISERVER:CLIENT:")) - lotsOfNines := make([]byte, 4096) - for i := 0; i < 4096; i++ { + lotsOfNines := make([]byte, 512) + for i := 0; i < 512; i++ { lotsOfNines[i] = '9' } @@ -327,7 +315,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: string(lotsOfNines), // <-- Make this field 4096 bytes long } @@ -335,19 +323,16 @@ func TestFSDClientLogin(t *testing.T) { _, err = conn.Write([]byte(clientIdentPDU.Serialize())) assert.Nil(t, err) - responseMsg, err := reader.ReadString('\n') + _, err = reader.ReadString('\n') assert.Nil(t, err) - expectedPDU := protocol.NewGenericFSDError(protocol.SyntaxError) - assert.Equal(t, expectedPDU.Serialize(), responseMsg) + var fsdError *protocol.FSDError + if errors.As(err, &fsdError) { + assert.True(t, strings.Contains(fsdError.Serialize(), "validation error")) + } _, err = reader.ReadString('\n') assert.NotNil(t, err) - var opError *net.OpError - if errors.As(err, &opError) { - assert.ErrorIs(t, opError.Err, syscall.ECONNRESET) - } else { - assert.Fail(t, "wrong error") - } + assert.ErrorIs(t, err, io.EOF) } // Test incorrect packet sequence @@ -367,8 +352,8 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, - Token: jwtResponse.Token, + CID: user1.CID, + Token: token, NetworkRating: 1, ProtocolRevision: 101, SimulatorType: 2, @@ -382,7 +367,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - expectedPDU := protocol.NewGenericFSDError(protocol.SyntaxError) + expectedPDU := protocol.NewGenericFSDError(protocol.SyntaxError, "", "invalid parameter count") assert.Equal(t, expectedPDU.Serialize(), responseMsg) conn.Close() @@ -418,7 +403,7 @@ func TestFSDClientLogin(t *testing.T) { From: "N123", To: "SERVER", CID: 9999999, - Token: jwtResponse.Token, + Token: token, NetworkRating: 1, ProtocolRevision: 101, SimulatorType: 2, @@ -434,7 +419,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError) + expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims (CID)") assert.Equal(t, expectedPDU.Serialize(), responseMsg) conn.Close() @@ -443,10 +428,10 @@ func TestFSDClientLogin(t *testing.T) { // Test token with invalid signature { // Fake jwt - token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.FSDJWTCustomClaims{ RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://auth.vatsim.net/api/fsd-jwt", - Subject: "1000000", + Issuer: "openfsd", + Subject: strconv.Itoa(user1.CID), Audience: []string{"fsd-live"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(420 * time.Second)), NotBefore: jwt.NewNumericDate(time.Now().Add(-120 * time.Second)), @@ -479,7 +464,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -487,7 +472,7 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, + CID: user1.CID, Token: tokenString, NetworkRating: 1, ProtocolRevision: 101, @@ -504,7 +489,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError) + expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token") assert.Equal(t, expectedPDU.Serialize(), responseMsg) conn.Close() @@ -513,11 +498,11 @@ func TestFSDClientLogin(t *testing.T) { // Test expired token { // Expired jwt - tokenStr, err := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + tokenStr, err := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.FSDJWTCustomClaims{ RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://auth.vatsim.net/api/fsd-jwt", - Subject: "1000000", - Audience: []string{"fsd-live"}, + Issuer: "openfsd", + Subject: strconv.Itoa(user1.CID), + Audience: []string{"server-live"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(-60 * time.Second)), NotBefore: jwt.NewNumericDate(time.Now().Add(-120 * time.Second)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -525,7 +510,7 @@ func TestFSDClientLogin(t *testing.T) { }, ControllerRating: 0, PilotRating: 0, - }).SignedString(JWTKey) + }).SignedString(servercontext.JWTKey()) assert.Nil(t, err) @@ -548,7 +533,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -556,7 +541,7 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, + CID: user1.CID, Token: tokenStr, NetworkRating: 1, ProtocolRevision: 101, @@ -573,7 +558,7 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError) + expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token") assert.Equal(t, expectedPDU.Serialize(), responseMsg) conn.Close() @@ -582,11 +567,11 @@ func TestFSDClientLogin(t *testing.T) { // Test token with invalid CID { // jwt with invalid CID - token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.FSDJWTCustomClaims{ RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "https://auth.vatsim.net/api/fsd-jwt", + Issuer: "openfsd", Subject: "9999999", - Audience: []string{"fsd-live"}, + Audience: []string{"server-live"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(420 * time.Second)), NotBefore: jwt.NewNumericDate(time.Now().Add(-120 * time.Second)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -596,7 +581,7 @@ func TestFSDClientLogin(t *testing.T) { PilotRating: 0, }) - tokenString, err := token.SignedString(JWTKey) + tokenString, err := token.SignedString(servercontext.JWTKey()) assert.Nil(t, err) conn, err := net.Dial("tcp", "localhost:6809") @@ -618,7 +603,7 @@ func TestFSDClientLogin(t *testing.T) { ClientName: "vPilot", MajorVersion: 3, MinorVersion: 8, - CID: 1000000, + CID: user1.CID, SysUID: -99999, InitialChallenge: "0123456789abcdef", } @@ -626,7 +611,7 @@ func TestFSDClientLogin(t *testing.T) { addPilotPDU := protocol.AddPilotPDU{ From: "N123", To: "SERVER", - CID: 1000000, + CID: user1.CID, Token: tokenString, NetworkRating: 1, ProtocolRevision: 101, @@ -643,9 +628,15 @@ func TestFSDClientLogin(t *testing.T) { assert.Nil(t, err) assert.NotEmpty(t, serverIdent) - expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError) + expectedPDU := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims (CID)") assert.Equal(t, expectedPDU.Serialize(), responseMsg) conn.Close() } + + // cancel context + cancelCtx() + + // wait until done + <-b.Done } diff --git a/test/stress_test.go b/test/stress_test.go new file mode 100644 index 0000000..c729e0b --- /dev/null +++ b/test/stress_test.go @@ -0,0 +1,121 @@ +package test + +import ( + "bytes" + "context" + "fmt" + "github.com/renorris/openfsd/bootstrap" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "github.com/stretchr/testify/assert" + "io" + "log" + "math/rand" + "net" + "os" + "sync" + "testing" + "time" +) + +func TestStressTest(t *testing.T) { + // Setup config for testing environment + if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil { + t.Fatal(err) + } + if err := os.Setenv("PLAINTEXT_PASSWORDS", "true"); err != nil { + t.Fatal(err) + } + + // Start the server + ctx, cancelCtx := context.WithCancel(context.Background()) + b := bootstrap.NewDefaultBootstrap() + if err := b.Start(ctx); err != nil { + t.Fatal(err) + } + + defer func() { + cancelCtx() + <-b.Done + }() + + user := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test User", + LastName: "Test User", + Password: "54321", + FSDPassword: "12345", + NetworkRating: protocol.NetworkRatingOBS, + PilotRating: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var err error + user.CID, err = user.Insert(servercontext.DB()) + assert.Nil(t, err) + + wg := sync.WaitGroup{} + for ci := 100001; ci < 100128; ci++ { + wg.Add(1) + time.Sleep(20 * time.Millisecond) + go func(ci int) { + defer wg.Done() + + log.Printf("Connecting %d", ci) + + var conn net.Conn + if conn, err = net.Dial("tcp4", "localhost:6809"); err != nil { + t.Fatal(err) + } + + go func() { + io.Copy(io.Discard, conn) + }() + + if _, err = io.Copy(conn, bytes.NewReader([]byte(fmt.Sprintf("$ID%d:SERVER:88e4:vPilot:3:8:%d:99999:1234567890\r\n", ci, user.CID)))); err != nil { + t.Fatal(err) + } + + if _, err = io.Copy(conn, bytes.NewReader([]byte(fmt.Sprintf("#AP%d:SERVER:%d:12345:1:101:2:Briner\r\n", ci, user.CID)))); err != nil { + t.Fatal(err) + } + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for i := range 100 { + randLat := randFloats(-90, 90, 1)[0] + randLon := randFloats(-180, 180, 1)[0] + + if i%5 == 0 { + if _, err = io.Copy(conn, bytes.NewReader([]byte(fmt.Sprintf("@S:%d:1200:1:%.6f:%.6f:16:0:4060:336\r\n", ci, randLat, randLon)))); err != nil { + t.Fatal(err) + } + } + + if _, err = io.Copy(conn, bytes.NewReader([]byte(fmt.Sprintf("^%d:%.6f:%.6f:20.20:8.62:944:-0.0001:-0.0017:0.0000:0.0000:0.0000:0.0000:0.00\r\n", ci, randLat, randLon)))); err != nil { + t.Fatal(err) + } + + <-ticker.C + } + + if _, err = io.Copy(conn, bytes.NewReader([]byte(fmt.Sprintf("#DP%d:%d\r\n", ci, user.CID)))); err != nil { + t.Fatal(err) + } + + }(ci) + } + + wg.Wait() +} + +func randFloats(min, max float64, n int) []float64 { + res := make([]float64, n) + for i := range res { + res[i] = min + rand.Float64()*(max-min) + } + return res +} diff --git a/test/users_api_test.go b/test/users_api_test.go new file mode 100644 index 0000000..3033299 --- /dev/null +++ b/test/users_api_test.go @@ -0,0 +1,283 @@ +package test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/bootstrap" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "github.com/renorris/openfsd/web" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/renorris/openfsd/database" + "github.com/stretchr/testify/assert" +) + +// MockVerifier is a mock implementation of JWTVerifier for testing +type MockVerifier struct { + Claims *auth.FSDJWTClaims + Err error +} + +func (mv *MockVerifier) VerifyJWT(tokenStr string) (token *jwt.Token, err error) { + if mv.Err != nil { + return nil, mv.Err + } + if mv.Claims != nil { + // Construct the token using the mock claims + now := time.Now() + t := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.FSDJWTCustomClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "openfsd", + Subject: strconv.Itoa(mv.Claims.CID()), + Audience: mv.Claims.Audience(), + ExpiresAt: jwt.NewNumericDate(now.Add(420 * time.Second)), + NotBefore: jwt.NewNumericDate(now.Add(-120 * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + ID: "randomrandomrandom", + }, + ControllerRating: int(mv.Claims.ControllerRating()), + PilotRating: int(mv.Claims.PilotRating()), + }) + + if tokenStr, err = t.SignedString(servercontext.JWTKey()); err != nil { + panic(err) + } + + if token, err = jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return servercontext.JWTKey(), nil + }, jwt.WithValidMethods([]string{"HS256"})); err != nil { + panic(err) + } + + return token, nil + } + return nil, errors.New("invalid token") +} + +func TestAPIV1UsersHandler(t *testing.T) { + + if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil { + t.Fatal(err) + } + + // Start the server + ctx, cancelCtx := context.WithCancel(context.Background()) + b := bootstrap.NewDefaultBootstrap() + if err := b.Start(ctx); err != nil { + t.Fatal(err) + } + + // Add demo user + demoRecord := database.FSDUserRecord{ + Email: "example@mail.com", + FirstName: "Test user 666", + LastName: "Test user 666 lastname", + Password: "12345", + FSDPassword: "54321", + NetworkRating: 1, + PilotRating: 0, + } + + // Insert it + var err error + if demoRecord.CID, err = demoRecord.Insert(servercontext.DB()); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + method string + authorization string + requestBody web.APIV1UsersRequest + expectedStatus int + expectedErrorMsg string + verifier *MockVerifier + }{ + { + name: "Missing Authorization", + method: "POST", + expectedStatus: http.StatusBadRequest, + expectedErrorMsg: "authorization header missing", + }, + { + name: "Invalid Bearer Token Format", + method: "POST", + authorization: "Basic token", + expectedStatus: http.StatusBadRequest, + expectedErrorMsg: "invalid authorization header format", + }, + { + name: "Invalid Token", + method: "POST", + authorization: "Bearer invalid_token", + expectedStatus: http.StatusForbidden, + expectedErrorMsg: "invalid token", + verifier: &MockVerifier{Claims: nil, Err: nil}, + }, + { + name: "Successful User Creation", + method: "POST", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 1, NetworkRating: 1}}, + expectedStatus: http.StatusOK, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Successful User Load", + method: "GET", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{CID: demoRecord.CID}, + expectedStatus: http.StatusOK, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Successful User Update", + method: "PUT", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{ + CID: demoRecord.CID, + Email: "newemail@example.com", + FirstName: "new first name", + LastName: "new last name", + Password: "newpassword", + FSDPassword: "newfsdpassword", + NetworkRating: 2, + PilotRating: 0, + }}, + expectedStatus: http.StatusOK, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Successful User Creation, but request is too long (> 8192 bytes)", + method: "POST", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 1, NetworkRating: 1, Email: strings.Repeat("b", 16384)}}, + expectedStatus: http.StatusInternalServerError, + expectedErrorMsg: "error reading request body", + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Forbidden User Creation by Non-Supervisor", + method: "POST", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 2, NetworkRating: 3}}, // Not enough rating to create user + expectedStatus: http.StatusForbidden, + expectedErrorMsg: "must be at least Supervisor to create user", + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingS1, protocol.PilotRatingPPL, []string{"dashboard"}), // Not a Supervisor + }, + }, + { + name: "Forbidden User Load by Non-Supervisor", + method: "GET", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{CID: 2}, // Not enough rating to read user + expectedStatus: http.StatusForbidden, + expectedErrorMsg: "must be at least Supervisor to read users", + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingS1, protocol.PilotRatingPPL, []string{"dashboard"}), // Not a Supervisor + }, + }, + { + name: "Forbidden User Creation of administrator by supervisor", + method: "POST", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{User: database.FSDUserRecord{CID: 322, NetworkRating: 12}}, // Not enough rating to create user + expectedStatus: http.StatusForbidden, + expectedErrorMsg: "created user must be below supervisor rating", + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingSUP, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Forbidden User Delete - lower than supervisor", + method: "DELETE", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{CID: demoRecord.CID}, + expectedStatus: http.StatusForbidden, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingI3, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "Successful User Delete", + method: "DELETE", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{CID: demoRecord.CID}, + expectedStatus: http.StatusOK, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"dashboard"}), + }, + }, + { + name: "invalid audience", + method: "DELETE", + authorization: "Bearer valid_token", + requestBody: web.APIV1UsersRequest{CID: demoRecord.CID}, + expectedStatus: http.StatusForbidden, + verifier: &MockVerifier{ + Claims: auth.NewFSDJWTClaims(1, protocol.NetworkRatingADM, protocol.PilotRatingPPL, []string{"fsd"}), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := new(bytes.Buffer) + json.NewEncoder(body).Encode(tt.requestBody) + + var req *http.Request + if tt.method == "GET" { + req = httptest.NewRequest(tt.method, "/api/v1/users/"+strconv.Itoa(tt.requestBody.CID), nil) + } else { + req = httptest.NewRequest(tt.method, "/api/v1/users", body) + } + + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + + rr := httptest.NewRecorder() + handler := func(w http.ResponseWriter, r *http.Request) { + web.APIV1UsersHandler(w, r, tt.verifier) // Inject the verifier + } + + handler(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + + if rr.Header().Get("Content-Type") != "application/json" { + t.Fatal("response type not application/json") + } + + if tt.expectedErrorMsg != "" { + var resp web.APIV1UsersResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err == nil { // Check for decode errors + assert.Equal(t, tt.expectedErrorMsg, resp.StatusMessage) + } + } + }) + } + + cancelCtx() + <-b.Done +} diff --git a/web/DATAFEED.md b/web/DATAFEED.md new file mode 100644 index 0000000..f4cc9c3 --- /dev/null +++ b/web/DATAFEED.md @@ -0,0 +1,41 @@ +## Datafeed Schema + +```json +{ + "general": { + "version": 0, // Major version of data feed + "update_timestamp": "2024-10-07T18:04:52.803041Z", // When the datafeed was last updated + "connected_clients": 0, // Total number of connected clients + "unique_users": 0 // Total number of connected users unique by CID + }, + "pilots": [ // List of online pilots + "callsign": "N12345", + "cid": 999999, + "name": "John Doe", + "pilot_rating": 1, + "latitude": 32.5, + "longitude", -117.5, + "altitude": 4502, + "groundspeed": 92, + "transponder": "2000", + "heading": 92, // Degrees magnetic + "last_updated": "2024-10-07T18:04:51.223342Z" // The time this pilot's information was last updated + ], + "ratings": [ // List of network ratings + { + "id": , + "short_name": "", + "long_name": "" + }, + ... + ], + "pilot_ratings": [ // List of pilot ratings + { + "id": , + "short_name": "", + "long_name": "" + }, + ... + ] +} +``` \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..a94f91f --- /dev/null +++ b/web/README.md @@ -0,0 +1,211 @@ +## openfsd REST Specification `/api/v1` + +## User Management: + +Object types: + +### User Ratings: + +| Rating | Value | +|---------------|-------| +| Inactive | -1 | +| Suspended | 0 | +| Observer | 1 | +| Student 1 | 2 | +| Student 2 | 3 | +| Student 3 | 4 | +| Controller 1 | 5 | +| Controller 2 | 6 | +| Controller 3 | 7 | +| Instructor 1 | 8 | +| Instructor 2 | 9 | +| Instructor 3 | 10 | +| Supervisor | 11 | +| Administrator | 12 | + +### Pilot Ratings: + +| Rating | Value | +|---------------------------------|-------| +| Basic Member | 0 | +| Private Pilot License | 1 | +| Instrument Rating | 3 | +| Commercial Multi-Engine License | 7 | +| Airline Transport Pilot License | 15 | +| Flight Instructor | 31 | +| Flight Examiner | 63 | + +### User Record: + +| Field | Type | Description | +|------------------|---------|--------------------------------------------------| +| `cid` | integer | Certificate ID | +| `email` | string | Email | +| `first_name` | string | First name | +| `last_name` | string | Last name | +| `password` | string | Primary account password | +| `fsd_password` | string | FSD password (a precaution as FSD is plaintext.) | +| `network_rating` | integer | Network (Controller) Rating | +| `pilot_rating` | integer | Pilot Rating | +| `updated_at` | integer | Last modified time (epoch seconds) | +| `created_at` | integer | Creation time (epoch seconds) | + +## Methods: + +The client must send a valid token in the Authorization header when making requests: +``` +Authorization: Bearer +``` + +### Response value +All `/api/v1/users` calls returning status `200` provide an application/json response body: + +```json +{ + "msg": , + "user": +} +``` + +### Error Response Codes +Error codes for user API calls are as follows: + +| Code | Description | +|-------|-------------------------| +| `400` | Bad request | +| `401` | Invalid authorization | +| `403` | Insufficient permission | +| `500` | Server error | + +### POST `/api/v1/users` + +Create a user record + +#### JSON Parameters: + +| Field | Type | Description | +|--------|-------------|----------------------------------------------------------------------| +| `user` | User Record | User Record to create (`cid` omitted. It is automatically assigned.) | + +#### Returns: +| Code | Description | +|-------|--------------------------| +| `200` | Success | + +#### Example: +``` +POST /api/v1/users +{ + user: { + password: "12345", + rating: 1, + ... rest of params + } +} +``` + +--- + +### GET `/api/v1/users/{cid}` + +Fetch a user record + +#### Path Request Parameter: +| Field | Type | Description | +|-------|---------|--------------| +| `cid` | integer | CID to query | + +#### Returns: +| Code | Description | +|-------|-------------------------| +| `200` | Success | +| `404` | User not found | + +#### JSON Response Payload: +| Field | Type | Description | +|--------|-------------|----------------------| +| `user` | User Record | returned user record | + +Example: + +``` +GET /api/v1/users/100000 + +{ + user: { + cid: 100000, + rating: 12, + created_at: "2024-09-22T23:21:05Z" + ... rest of params + } +} +``` + +___ + +### PUT `/api/v1/users` + +Update a user record. + +All fields *must* be set as per `PUT` convention, except for +`password` and `fsd_password`, which are optional. + +#### JSON Parameters: +| Field | Type | Description | +|--------|-------------|-----------------------| +| `user` | User Record | User record to update | + +#### Returns: +| Code | Description | +|-------|-------------------------| +| `201` | Success | +| `404` | CID not found | + +``` +PUT /api/v1/users + +{ + user: { + cid: 100002, // CID to update + password: "12345", // New password + rating: 10, // Changed rating + ... rest of params (ALL REQUIRED) + } +} + +{ + msg: "success", + user: { + ... updated user + } +} +``` + +--- + +### DELETE `/api/v1/users` + +Delete a user record + +#### JSON Request Parameters: +| Field | Type | Description | +|--------|-------------|---------------------| +| `cid` | integer | CID to delete | + +#### Returns: +| Code | Description | +|-------|-------------------------| +| `204` | Success | +| `404` | User not found | + +Example: + +``` +DELETE /api/v1/users + +Request payload: +{ + "cid": 100002 +} + +``` \ No newline at end of file diff --git a/web/datafeed.go b/web/datafeed.go new file mode 100644 index 0000000..5baa2f5 --- /dev/null +++ b/web/datafeed.go @@ -0,0 +1,20 @@ +package web + +import ( + "bytes" + "github.com/renorris/openfsd/servercontext" + "io" + "net/http" +) + +func DataFeedHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + feed, lastModified := servercontext.DataFeed().Feed() + w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat)) + w.Header().Set("Cache-Control", "public, max-age=15") + + w.WriteHeader(200) + + io.Copy(w, bytes.NewReader([]byte(feed))) +} diff --git a/web/favicon_handler.go b/web/favicon_handler.go new file mode 100644 index 0000000..257abff --- /dev/null +++ b/web/favicon_handler.go @@ -0,0 +1,18 @@ +package web + +import ( + "bytes" + _ "embed" + "io" + "net/http" +) + +//go:embed static/favicon.ico +var favicon []byte + +func FaviconHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/x-icon") + w.WriteHeader(200) + + io.Copy(w, bytes.NewReader(favicon)) +} diff --git a/web/frontend_handler.go b/web/frontend_handler.go new file mode 100644 index 0000000..1d85d81 --- /dev/null +++ b/web/frontend_handler.go @@ -0,0 +1,310 @@ +package web + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "github.com/golang-jwt/jwt/v5" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "golang.org/x/crypto/bcrypt" + "net/http" + "slices" + "strconv" + "time" +) + +//go:embed static +var StaticFS embed.FS + +// FrontendHandler handles UI-related HTTP calls +func FrontendHandler(w http.ResponseWriter, r *http.Request) { + + r.Body = http.MaxBytesReader(w, r.Body, 1024) + + switch r.URL.Path { + case "/login": + loginHandler(w, r) + case "/logout": + logoutHandler(w, r) + case "/dashboard": + dashboardHandler(w, r) + case "/admin_dashboard": + adminDashboardHandler(w, r) + case "/changepassword": + changePasswordHandler(w, r) + case "/": + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + default: + http.NotFound(w, r) + } +} + +func loginHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Load login page + if err := RenderTemplate(w, "login.html", nil); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + case "POST": + // Handle login + if err := r.ParseForm(); err != nil { + http.Error(w, "unable to parse form values", http.StatusBadRequest) + } + + var cid, password string + if cid = r.PostForm.Get("cid"); cid == "" { + http.Error(w, "CID query parameter not found", http.StatusBadRequest) + return + } + if password = r.PostForm.Get("password"); password == "" { + http.Error(w, "password query parameter not found", http.StatusBadRequest) + return + } + + var cidInt int + var err error + if cidInt, err = strconv.Atoi(cid); err != nil { + http.Error(w, "invalid CID", http.StatusBadRequest) + return + } + + // Load user record from database + userRecord := database.FSDUserRecord{} + if err = userRecord.LoadByCID(servercontext.DB(), cidInt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "invalid login", http.StatusUnauthorized) + } else { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + // Verify password + if err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(password)); err != nil { + http.Error(w, "invalid login", http.StatusUnauthorized) + return + } + + // Verify account standing + if userRecord.NetworkRating <= protocol.NetworkRatingSUS { + http.Error(w, "account suspended/inactive", http.StatusForbidden) + return + } + + // Administer a token + // Use "dashboard" audience to specify that this is a web frontend token; not for connecting to FSD. + claims := auth.NewFSDJWTClaims( + userRecord.CID, userRecord.NetworkRating, + userRecord.PilotRating, []string{"dashboard"}) + + now := time.Now() + expires := now.Add(24 * time.Hour) + + var token string + if token, err = claims.MakeToken(expires); err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Set-Cookie", fmt.Sprintf("token=%s; Expires=%s", token, expires.Format(http.TimeFormat))) + w.WriteHeader(http.StatusNoContent) + return + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + deleteCookie(w, "token") + http.Redirect(w, r, "/login", http.StatusSeeOther) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func dashboardHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + var userRecord *database.FSDUserRecord + var err error + if userRecord, _, err = frontendSessionMiddleware(w, r); err != nil { + return + } + + if err = RenderTemplate(w, "dashboard.html", DashboardPageData{UserRecord: userRecord}); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func changePasswordHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + var userRecord *database.FSDUserRecord + var err error + if userRecord, _, err = frontendSessionMiddleware(w, r); err != nil { + return + } + + // Handle form parameters + if err := r.ParseForm(); err != nil { + http.Error(w, "unable to parse form values", http.StatusBadRequest) + } + + var oldPassword, newPassword string + var changeFSDPassword bool + oldPassword = r.PostForm.Get("old_password") + if newPassword = r.PostForm.Get("new_password"); newPassword == "" { + http.Error(w, "new password query parameter not found", http.StatusBadRequest) + return + } + if changeFSDPasswordStr := r.PostForm.Get("change_fsd_password"); changeFSDPasswordStr == "" { + http.Error(w, "change fsd password query parameter not found", http.StatusBadRequest) + return + } else { + switch changeFSDPasswordStr { + case "true": + changeFSDPassword = true + case "false": + changeFSDPassword = false + default: + http.Error(w, "change fsd password query parameter must be true or false", http.StatusBadRequest) + return + } + } + + if len(newPassword) < 8 { + http.Error(w, "password must be 8 or more characters", http.StatusBadRequest) + return + } + + if changeFSDPassword { + userRecord.Password = "" + userRecord.FSDPassword = newPassword + } else { + + if err = bcrypt.CompareHashAndPassword([]byte(userRecord.Password), []byte(oldPassword)); err != nil { + http.Error(w, "old password is incorrect", http.StatusUnauthorized) + return + } + + userRecord.FSDPassword = "" + userRecord.Password = newPassword + } + + if err = userRecord.Update(servercontext.DB()); err != nil { + http.Error(w, "unable to update user record", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + return + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func adminDashboardHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + + var claims *auth.FSDJWTClaims + var err error + if _, claims, err = frontendSessionMiddleware(w, r); err != nil { + return + } + + if claims.ControllerRating() < protocol.NetworkRatingSUP { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + if err = RenderTemplate(w, "admin_dashboard.html", nil); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func deleteCookie(w http.ResponseWriter, name string) { + w.Header().Set("Set-Cookie", fmt.Sprintf("%s=; Expires=%s", name, time.Unix(0, 0).Format(http.TimeFormat))) +} + +func frontendSessionMiddleware(w http.ResponseWriter, r *http.Request) (userRecord *database.FSDUserRecord, claims *auth.FSDJWTClaims, err error) { + // Get token cookie + var tokenStr string + if cookies := r.CookiesNamed("token"); len(cookies) != 1 { + deleteCookie(w, "token") + http.Redirect(w, r, "/login", http.StatusSeeOther) + err = errors.New("invalid cookie length") + return + } else { + tokenStr = cookies[0].Value + } + + // Validate token + var token *jwt.Token + if token, err = (auth.DefaultVerifier{}).VerifyJWT(tokenStr); err != nil { + deleteCookie(w, "token") + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Parse claims + claims = &auth.FSDJWTClaims{} + if err = claims.Parse(token); err != nil { + http.Error(w, "invalid token claims", http.StatusBadRequest) + return + } + + if !slices.Contains(claims.Audience(), "dashboard") { + http.Error(w, "invalid token audience", http.StatusBadRequest) + err = errors.New("token claims does not include 'dashboard'") + return + } + + // Load user record + userRecord = &database.FSDUserRecord{} + if err = userRecord.LoadByCID(servercontext.DB(), claims.CID()); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "invalid CID", http.StatusForbidden) + } else { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + // Verify claims match database record + if userRecord.CID != claims.CID() { + http.Error(w, "claimed CID does not match CID on record", http.StatusForbidden) + return + } + if userRecord.NetworkRating != claims.ControllerRating() { + http.Error(w, "claimed network rating does not match rating on record", http.StatusForbidden) + return + } + if userRecord.PilotRating != claims.PilotRating() { + http.Error(w, "claimed pilot rating does not match rating on record", http.StatusForbidden) + return + } + + return userRecord, claims, nil +} diff --git a/web/fsd-jwt_handler.go b/web/fsd-jwt_handler.go new file mode 100644 index 0000000..5e9e09e --- /dev/null +++ b/web/fsd-jwt_handler.go @@ -0,0 +1,95 @@ +package web + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "golang.org/x/crypto/bcrypt" + "io" + "net/http" + "strconv" + "time" +) + +// FSDJWTHandler administers tokens for base-level privilege FSD server connections +func FSDJWTHandler(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 256) + + w.Header().Set("Content-Type", "application/json") + resp := auth.FSDJWTResponse{} + var respBytes []byte + + // Load the request body + var body []byte + var err error + if body, err = io.ReadAll(r.Body); err != nil { + resp.Success, resp.ErrorMsg = false, "error reading request body" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + // Parse the request body + var req auth.FSDJWTRequest + if err = json.Unmarshal(body, &req); err != nil { + resp.Success, resp.ErrorMsg = false, "invalid request body" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + // Parse CID integer + var cidInt int + if cidInt, err = strconv.Atoi(req.CID); err != nil { + resp.Success, resp.ErrorMsg = false, "invalid CID" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + // Load user record from database + userRecord := database.FSDUserRecord{} + if err = userRecord.LoadByCID(servercontext.DB(), cidInt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + resp.Success, resp.ErrorMsg = false, "invalid CID" + writeResponseError(w, http.StatusUnauthorized, &resp) + return + } + + resp.Success, resp.ErrorMsg = false, "internal server error" + writeResponseError(w, http.StatusInternalServerError, &resp) + return + } + + // Verify password hash + if err = bcrypt.CompareHashAndPassword([]byte(userRecord.FSDPassword), []byte(req.Password)); err != nil { + resp.Success, resp.ErrorMsg = false, "invalid credentials" + writeResponseError(w, http.StatusUnauthorized, &resp) + return + } + + // Verify account standing + if userRecord.NetworkRating <= protocol.NetworkRatingSUS { + resp.Success, resp.ErrorMsg = false, "account suspended/inactive" + writeResponseError(w, http.StatusForbidden, &resp) + return + } + + // All good. Administer the token. + // use "fsd" audience to specify that this token is only valid for connecting to FSD. + claims := auth.NewFSDJWTClaims(userRecord.CID, userRecord.NetworkRating, userRecord.PilotRating, []string{"fsd"}) + + var token string + if token, err = claims.MakeToken(time.Now().Add(420 * time.Second)); err != nil { + resp.Success, resp.ErrorMsg = false, "internal server error" + writeResponseError(w, http.StatusInternalServerError, &resp) + return + } + + resp.Success, resp.Token = true, token + if respBytes, err = json.Marshal(resp); err == nil { + io.Copy(w, bytes.NewReader(respBytes)) + } +} diff --git a/web/static/css/bootstrap.min.css b/web/static/css/bootstrap.min.css new file mode 100644 index 0000000..3993414 --- /dev/null +++ b/web/static/css/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000..2579cc8 Binary files /dev/null and b/web/static/favicon.ico differ diff --git a/web/static/js/admin_dashboard.js b/web/static/js/admin_dashboard.js new file mode 100644 index 0000000..edfb772 --- /dev/null +++ b/web/static/js/admin_dashboard.js @@ -0,0 +1,219 @@ +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + +const showResponse = (msg) => { + $('#responseBody').text(msg); + const toast = new bootstrap.Toast($('#responseToast')); + toast.show(); +}; + +$(document).ready(() => { + const token = getCookie("token") + + const userFetchModal = $("#user-fetch-modal") + const userEditModal = $("#user-edit-modal") + const userCreateModal = $("#user-create-modal") + + const userCreateStatusMsg = $("#user-create-statusmsg") + + const userEditCID = $("#user-edit-cid-header") + const userEditEmail = $("#user-edit-email") + const userEditFirstname = $("#user-edit-firstname") + const userEditLastname = $("#user-edit-lastname") + const userEditNetworkRating = $("#user-edit-networkrating") + const userEditPilotRating = $("#user-edit-pilotrating") + const userEditLastUpdated = $("#user-edit-lastupdated") + const userEditCreatedAt = $("#user-edit-createdat") + + const userEditPassword = $("#user-edit-password") + const userEditFSDPassword = $("#user-edit-fsdpassword") + + const userEditUpdateButton = $("#user-edit-updatebutton") + const userEditDeleteButton = $("#user-edit-deletebutton") + + const userCreateEmail = $("#user-create-email") + const userCreateFirstname = $("#user-create-firstname") + const userCreateLastname = $("#user-create-lastname") + const userCreateNetworkRating = $("#user-create-networkrating") + const userCreatePilotRating = $("#user-create-pilotrating") + const userCreatePassword = $("#user-create-password") + const userCreateFSDPassword = $("#user-create-fsdpassword") + + const userCreateSubmitButton = $("#user-create-submitbutton") + + const userFetchButton = $("#user-fetch-button") + const userCreateButton = $("#user-create-button") + + const userFetchCID = $("#user-fetch-cid") + const userFetchInitiate = $("#user-fetch-initiate") + + const toastClipboardButton = $("#toast-clipboard-button") + + userFetchButton.click(() => { + let modal = new bootstrap.Modal(userFetchModal) + modal.show() + }) + + userCreateButton.click(() => { + let modal = new bootstrap.Modal(userCreateModal) + modal.show() + }) + + userFetchInitiate.click(() => { + new bootstrap.Modal(userFetchModal).hide() + + if (userFetchCID.val() === "") { + showResponse("Error: CID empty") + return + } + + new bootstrap.Modal(userFetchModal).hide() + + // Fetch users + $.ajax({ + type: "GET", + url: `/api/v1/users/${parseInt(userFetchCID.val())}`, + dataType: "json", + headers: { + "Authorization": `Bearer ${token}`, + }, + success: function (data, textStatus, jqXHR) { + userEditCID.text(data.user.cid) + userEditEmail.val(data.user.email) + userEditFirstname.val(data.user.first_name) + userEditLastname.val(data.user.last_name) + userEditNetworkRating.val(data.user.network_rating) + userEditNetworkRating.change() + userEditPilotRating.val(data.user.pilot_rating) + userEditPilotRating.change() + userEditLastUpdated.text(data.user.updated_at) + userEditCreatedAt.text(data.user.created_at) + new bootstrap.Modal(userEditModal).show() + }, + error: function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nMessage: ' + JSON.parse(jqXHR.responseText).msg) + } + }); + }) + + userEditUpdateButton.click(() => { + + if (!window.confirm(`Are you sure you want to update CID ${userEditCID.text()}?`)) { + return + } + + // Update user + $.ajax({ + type: "PUT", + url: `/api/v1/users`, + contentType: "application/json", + headers: { + "Authorization": `Bearer ${token}`, + }, + data: JSON.stringify({ + cid: parseInt(userEditCID.text()), + user: { + cid: parseInt(userEditCID.text()), + email: userEditEmail.val(), + first_name: userEditFirstname.val(), + last_name: userEditLastname.val(), + network_rating: parseInt(userEditNetworkRating.val()), + pilot_rating: parseInt(userEditPilotRating.val()), + password: userEditPassword.val(), + fsd_password: userEditFSDPassword.val(), + } + }), + success: function (data, textStatus, jqXHR) { + showResponse(`Successfully updated user ${userEditCID.text()}`) + new bootstrap.Modal(userEditModal).hide() + }, + error: function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nMessage: ' + JSON.parse(jqXHR.responseText).msg) + } + }); + }) + + userEditDeleteButton.click(() => { + + if (!window.confirm(`Are you sure you want to delete CID ${userEditCID.text()}?`)) { + return + } + + // Delete user + $.ajax({ + type: "DELETE", + url: `/api/v1/users`, + contentType: "application/json", + headers: { + "Authorization": `Bearer ${token}`, + }, + data: JSON.stringify({ + cid: parseInt(userEditCID.text()), + }), + success: function (data, textStatus, jqXHR) { + showResponse(`Successfully deleted user ${userEditCID.text()}`) + new bootstrap.Modal(userEditModal).hide() + }, + error: function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nMessage: ' + JSON.parse(jqXHR.responseText).msg) + } + }); + }) + + userCreateSubmitButton.click(() => { + + if (userCreatePassword === "" || userCreateFSDPassword === "") { + showResponse("error: password is empty") + return + } + + // Create user + $.ajax({ + type: "POST", + url: `/api/v1/users`, + contentType: "application/json", + headers: { + "Authorization": `Bearer ${token}`, + }, + data: JSON.stringify({ + user: { + email: userCreateEmail.val(), + first_name: userCreateFirstname.val(), + last_name: userCreateLastname.val(), + network_rating: parseInt(userCreateNetworkRating.val()), + pilot_rating: parseInt(userCreatePilotRating.val()), + password: userCreatePassword.val(), + fsd_password: userCreateFSDPassword.val(), + } + }), + success: function (data, textStatus, jqXHR) { + + let pwdMsg = userCreatePassword.val() === "" ? `\nPassword: ${data.user.password}` : `` + userCreateFSDPassword.val() === "" ? pwdMsg += `\nFSD Password: ${data.user.fsd_password}` : `` + + showResponse(`Successfully created user: CID = ${data.user.cid}\n${pwdMsg}`) + new bootstrap.Modal(userCreateModal).hide() + }, + error: function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nMessage: ' + JSON.parse(jqXHR.responseText).msg) + } + }); + }) + + toastClipboardButton.click(() => { + let text = $("#responseBody").text() + navigator.clipboard.writeText(text).then(r => { + $("#toast-copy-banner").text("Copied!") + setTimeout(() => { + $("#toast-copy-banner").text("") + }, 2000) + }); + }) +}) \ No newline at end of file diff --git a/web/static/js/alert.js b/web/static/js/alert.js new file mode 100644 index 0000000..77bf988 --- /dev/null +++ b/web/static/js/alert.js @@ -0,0 +1,13 @@ +function setAlert(id, message, color) { + const html = ` + ` + + $('#' + id).html(html) +} + +function clearAlert(id) { + $('#' + id).html('') +} \ No newline at end of file diff --git a/web/static/js/bootstrap.min.js b/web/static/js/bootstrap.min.js new file mode 100644 index 0000000..d5dc5ea --- /dev/null +++ b/web/static/js/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(s)&&P(t,e,i,r.callable,r.delegationSelector)}function M(t){return t=t.replace(E,""),$[t]||t}const j={on(t,e,i,s){N(t,e,i,s,!1)},one(t,e,i,s){N(t,e,i,s,!0)},off(t,e,i,s){if("string"!=typeof e||!t)return;const[n,o,r]=D(e,i,s),a=r!==e,l=O(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))x(t,l,i,e.slice(1));for(const[i,s]of Object.entries(c)){const n=i.replace(C,"");a&&!e.includes(n)||P(t,l,r,s.callable,s.delegationSelector)}}else{if(!Object.keys(c).length)return;P(t,l,r,o,n?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const s=f();let n=null,o=!0,r=!0,a=!1;e!==M(e)&&s&&(n=s.Event(e,i),s(t).trigger(n),o=!n.isPropagationStopped(),r=!n.isImmediatePropagationStopped(),a=n.isDefaultPrevented());const l=F(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&n&&n.preventDefault(),l}};function F(t,e={}){for(const[i,s]of Object.entries(e))try{t[i]=s}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>s})}return t}function z(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function H(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const B={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${H(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${H(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const s of i){let i=s.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=z(t.dataset[s])}return e},getDataAttribute:(t,e)=>z(t.getAttribute(`data-bs-${H(e)}`))};class q{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=l(e)?B.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...l(e)?B.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[s,n]of Object.entries(e)){const e=t[s],o=l(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(n).test(o))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${s}" provided type "${o}" but expected type "${n}".`)}var i}}class W extends q{constructor(t,e){super(),(t=c(t))&&(this._element=t,this._config=this._getConfig(e),n.set(this._element,this.constructor.DATA_KEY,this))}dispose(){n.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){y(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return n.get(c(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const R=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>r(t))).join(","):null},K={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let s=t.parentNode.closest(e);for(;s;)i.push(s),s=s.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!d(t)&&h(t)))},getSelectorFromElement(t){const e=R(t);return e&&K.findOne(e)?e:null},getElementFromSelector(t){const e=R(t);return e?K.findOne(e):null},getMultipleElementsFromSelector(t){const e=R(t);return e?K.find(e):[]}},V=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),d(this))return;const n=K.getElementFromSelector(this)||this.closest(`.${s}`);t.getOrCreateInstance(n)[e]()}))},Q=".bs.alert",X=`close${Q}`,Y=`closed${Q}`;class U extends W{static get NAME(){return"alert"}close(){if(j.trigger(this._element,X).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,Y),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=U.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}V(U,"close"),b(U);const G='[data-bs-toggle="button"]';class J extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=J.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}j.on(document,"click.bs.button.data-api",G,(t=>{t.preventDefault();const e=t.target.closest(G);J.getOrCreateInstance(e).toggle()})),b(J);const Z=".bs.swipe",tt=`touchstart${Z}`,et=`touchmove${Z}`,it=`touchend${Z}`,st=`pointerdown${Z}`,nt=`pointerup${Z}`,ot={endCallback:null,leftCallback:null,rightCallback:null},rt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class at extends q{constructor(t,e){super(),this._element=t,t&&at.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return ot}static get DefaultType(){return rt}static get NAME(){return"swipe"}dispose(){j.off(this._element,Z)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),v(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&v(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(j.on(this._element,st,(t=>this._start(t))),j.on(this._element,nt,(t=>this._end(t))),this._element.classList.add("pointer-event")):(j.on(this._element,tt,(t=>this._start(t))),j.on(this._element,et,(t=>this._move(t))),j.on(this._element,it,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const lt=".bs.carousel",ct=".data-api",ht="next",dt="prev",ut="left",_t="right",gt=`slide${lt}`,ft=`slid${lt}`,mt=`keydown${lt}`,pt=`mouseenter${lt}`,bt=`mouseleave${lt}`,vt=`dragstart${lt}`,yt=`load${lt}${ct}`,wt=`click${lt}${ct}`,At="carousel",Et="active",Ct=".active",Tt=".carousel-item",kt=Ct+Tt,$t={ArrowLeft:_t,ArrowRight:ut},St={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Lt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Ot extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=K.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===At&&this.cycle()}static get Default(){return St}static get DefaultType(){return Lt}static get NAME(){return"carousel"}next(){this._slide(ht)}nextWhenVisible(){!document.hidden&&h(this._element)&&this.next()}prev(){this._slide(dt)}pause(){this._isSliding&&a(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?j.one(this._element,ft,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,ft,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const s=t>i?ht:dt;this._slide(s,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&j.on(this._element,mt,(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,pt,(()=>this.pause())),j.on(this._element,bt,(()=>this._maybeEnableCycle()))),this._config.touch&&at.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of K.find(".carousel-item img",this._element))j.on(t,vt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ut)),rightCallback:()=>this._slide(this._directionToOrder(_t)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new at(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=$t[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=K.findOne(Ct,this._indicatorsElement);e.classList.remove(Et),e.removeAttribute("aria-current");const i=K.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(Et),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),s=t===ht,n=e||w(this._getItems(),i,s,this._config.wrap);if(n===i)return;const o=this._getItemIndex(n),r=e=>j.trigger(this._element,e,{relatedTarget:n,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(gt).defaultPrevented)return;if(!i||!n)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=n;const l=s?"carousel-item-start":"carousel-item-end",c=s?"carousel-item-next":"carousel-item-prev";n.classList.add(c),g(n),i.classList.add(l),n.classList.add(l),this._queueCallback((()=>{n.classList.remove(l,c),n.classList.add(Et),i.classList.remove(Et,c,l),this._isSliding=!1,r(ft)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return K.findOne(kt,this._element)}_getItems(){return K.find(Tt,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ut?dt:ht:t===ut?ht:dt}_orderToDirection(t){return p()?t===dt?ut:_t:t===dt?_t:ut}static jQueryInterface(t){return this.each((function(){const e=Ot.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}j.on(document,wt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=K.getElementFromSelector(this);if(!e||!e.classList.contains(At))return;t.preventDefault();const i=Ot.getOrCreateInstance(e),s=this.getAttribute("data-bs-slide-to");return s?(i.to(s),void i._maybeEnableCycle()):"next"===B.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),j.on(window,yt,(()=>{const t=K.find('[data-bs-ride="carousel"]');for(const e of t)Ot.getOrCreateInstance(e)})),b(Ot);const It=".bs.collapse",Dt=`show${It}`,Nt=`shown${It}`,Pt=`hide${It}`,xt=`hidden${It}`,Mt=`click${It}.data-api`,jt="show",Ft="collapse",zt="collapsing",Ht=`:scope .${Ft} .${Ft}`,Bt='[data-bs-toggle="collapse"]',qt={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Rt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=K.find(Bt);for(const t of i){const e=K.getSelectorFromElement(t),i=K.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return qt}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Rt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(j.trigger(this._element,Dt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Ft),this._element.classList.add(zt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft,jt),this._element.style[e]="",j.trigger(this._element,Nt)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,Pt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,g(this._element),this._element.classList.add(zt),this._element.classList.remove(Ft,jt);for(const t of this._triggerArray){const e=K.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft),j.trigger(this._element,xt)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(jt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=c(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Bt);for(const e of t){const t=K.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=K.find(Ht,this._config.parent);return K.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Rt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,Mt,Bt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of K.getMultipleElementsFromSelector(this))Rt.getOrCreateInstance(t,{toggle:!1}).toggle()})),b(Rt);const Kt="dropdown",Vt=".bs.dropdown",Qt=".data-api",Xt="ArrowUp",Yt="ArrowDown",Ut=`hide${Vt}`,Gt=`hidden${Vt}`,Jt=`show${Vt}`,Zt=`shown${Vt}`,te=`click${Vt}${Qt}`,ee=`keydown${Vt}${Qt}`,ie=`keyup${Vt}${Qt}`,se="show",ne='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',oe=`${ne}.${se}`,re=".dropdown-menu",ae=p()?"top-end":"top-start",le=p()?"top-start":"top-end",ce=p()?"bottom-end":"bottom-start",he=p()?"bottom-start":"bottom-end",de=p()?"left-start":"right-start",ue=p()?"right-start":"left-start",_e={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},ge={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class fe extends W{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=K.next(this._element,re)[0]||K.prev(this._element,re)[0]||K.findOne(re,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return _e}static get DefaultType(){return ge}static get NAME(){return Kt}toggle(){return this._isShown()?this.hide():this.show()}show(){if(d(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!j.trigger(this._element,Jt,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(se),this._element.classList.add(se),j.trigger(this._element,Zt,t)}}hide(){if(d(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!j.trigger(this._element,Ut,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._popper&&this._popper.destroy(),this._menu.classList.remove(se),this._element.classList.remove(se),this._element.setAttribute("aria-expanded","false"),B.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,Gt,t)}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!l(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Kt.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===i)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let t=this._element;"parent"===this._config.reference?t=this._parent:l(this._config.reference)?t=c(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=i.createPopper(t,this._menu,e)}_isShown(){return this._menu.classList.contains(se)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return de;if(t.classList.contains("dropstart"))return ue;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?le:ae:e?he:ce}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(B.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...v(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=K.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>h(t)));i.length&&w(i,e,t===Yt,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=K.find(oe);for(const i of e){const e=fe.getInstance(i);if(!e||!1===e._config.autoClose)continue;const s=t.composedPath(),n=s.includes(e._menu);if(s.includes(e._element)||"inside"===e._config.autoClose&&!n||"outside"===e._config.autoClose&&n)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,s=[Xt,Yt].includes(t.key);if(!s&&!i)return;if(e&&!i)return;t.preventDefault();const n=this.matches(ne)?this:K.prev(this,ne)[0]||K.next(this,ne)[0]||K.findOne(ne,t.delegateTarget.parentNode),o=fe.getOrCreateInstance(n);if(s)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),n.focus())}}j.on(document,ee,ne,fe.dataApiKeydownHandler),j.on(document,ee,re,fe.dataApiKeydownHandler),j.on(document,te,fe.clearMenus),j.on(document,ie,fe.clearMenus),j.on(document,te,ne,(function(t){t.preventDefault(),fe.getOrCreateInstance(this).toggle()})),b(fe);const me="backdrop",pe="show",be=`mousedown.bs.${me}`,ve={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},ye={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class we extends q{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ve}static get DefaultType(){return ye}static get NAME(){return me}show(t){if(!this._config.isVisible)return void v(t);this._append();const e=this._getElement();this._config.isAnimated&&g(e),e.classList.add(pe),this._emulateAnimation((()=>{v(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(pe),this._emulateAnimation((()=>{this.dispose(),v(t)}))):v(t)}dispose(){this._isAppended&&(j.off(this._element,be),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=c(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),j.on(t,be,(()=>{v(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){y(t,this._getElement(),this._config.isAnimated)}}const Ae=".bs.focustrap",Ee=`focusin${Ae}`,Ce=`keydown.tab${Ae}`,Te="backward",ke={autofocus:!0,trapElement:null},$e={autofocus:"boolean",trapElement:"element"};class Se extends q{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ke}static get DefaultType(){return $e}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),j.off(document,Ae),j.on(document,Ee,(t=>this._handleFocusin(t))),j.on(document,Ce,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,Ae))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=K.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===Te?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Te:"forward")}}const Le=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",Oe=".sticky-top",Ie="padding-right",De="margin-right";class Ne{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ie,(e=>e+t)),this._setElementAttributes(Le,Ie,(e=>e+t)),this._setElementAttributes(Oe,De,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ie),this._resetElementAttributes(Le,Ie),this._resetElementAttributes(Oe,De)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const s=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+s)return;this._saveInitialAttribute(t,e);const n=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(n))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&B.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=B.getDataAttribute(t,e);null!==i?(B.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(l(t))e(t);else for(const i of K.find(t,this._element))e(i)}}const Pe=".bs.modal",xe=`hide${Pe}`,Me=`hidePrevented${Pe}`,je=`hidden${Pe}`,Fe=`show${Pe}`,ze=`shown${Pe}`,He=`resize${Pe}`,Be=`click.dismiss${Pe}`,qe=`mousedown.dismiss${Pe}`,We=`keydown.dismiss${Pe}`,Re=`click${Pe}.data-api`,Ke="modal-open",Ve="show",Qe="modal-static",Xe={backdrop:!0,focus:!0,keyboard:!0},Ye={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ue extends W{constructor(t,e){super(t,e),this._dialog=K.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ne,this._addEventListeners()}static get Default(){return Xe}static get DefaultType(){return Ye}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,Fe,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ke),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(j.trigger(this._element,xe).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Ve),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){j.off(window,Pe),j.off(this._dialog,Pe),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new we({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=K.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),g(this._element),this._element.classList.add(Ve),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,ze,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){j.on(this._element,We,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),j.on(window,He,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),j.on(this._element,qe,(t=>{j.one(this._element,Be,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Ke),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,je)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,Me).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Qe)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Qe),this._queueCallback((()=>{this._element.classList.remove(Qe),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ue.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,Re,'[data-bs-toggle="modal"]',(function(t){const e=K.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,Fe,(t=>{t.defaultPrevented||j.one(e,je,(()=>{h(this)&&this.focus()}))}));const i=K.findOne(".modal.show");i&&Ue.getInstance(i).hide(),Ue.getOrCreateInstance(e).toggle(this)})),V(Ue),b(Ue);const Ge=".bs.offcanvas",Je=".data-api",Ze=`load${Ge}${Je}`,ti="show",ei="showing",ii="hiding",si=".offcanvas.show",ni=`show${Ge}`,oi=`shown${Ge}`,ri=`hide${Ge}`,ai=`hidePrevented${Ge}`,li=`hidden${Ge}`,ci=`resize${Ge}`,hi=`click${Ge}${Je}`,di=`keydown.dismiss${Ge}`,ui={backdrop:!0,keyboard:!0,scroll:!1},_i={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class gi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return ui}static get DefaultType(){return _i}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,ni,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ne).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(ei),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(ti),this._element.classList.remove(ei),j.trigger(this._element,oi,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,ri).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ii),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(ti,ii),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ne).reset(),j.trigger(this._element,li)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new we({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():j.trigger(this._element,ai)}:null})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_addEventListeners(){j.on(this._element,di,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():j.trigger(this._element,ai))}))}static jQueryInterface(t){return this.each((function(){const e=gi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,hi,'[data-bs-toggle="offcanvas"]',(function(t){const e=K.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this))return;j.one(e,li,(()=>{h(this)&&this.focus()}));const i=K.findOne(si);i&&i!==e&&gi.getInstance(i).hide(),gi.getOrCreateInstance(e).toggle(this)})),j.on(window,Ze,(()=>{for(const t of K.find(si))gi.getOrCreateInstance(t).show()})),j.on(window,ci,(()=>{for(const t of K.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&gi.getOrCreateInstance(t).hide()})),V(gi),b(gi);const fi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},mi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),pi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bi=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!mi.has(i)||Boolean(pi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},vi={allowList:fi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},yi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},wi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ai extends q{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return vi}static get DefaultType(){return yi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},wi)}_setContent(t,e,i){const s=K.findOne(i,t);s&&((e=this._resolvePossibleFunction(e))?l(e)?this._putElementInTemplate(c(e),s):this._config.html?s.innerHTML=this._maybeSanitize(e):s.textContent=e:s.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const s=(new window.DOMParser).parseFromString(t,"text/html"),n=[].concat(...s.body.querySelectorAll("*"));for(const t of n){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const s=[].concat(...t.attributes),n=[].concat(e["*"]||[],e[i]||[]);for(const e of s)bi(e,n)||t.removeAttribute(e.nodeName)}return s.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return v(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ei=new Set(["sanitize","allowList","sanitizeFn"]),Ci="fade",Ti="show",ki=".modal",$i="hide.bs.modal",Si="hover",Li="focus",Oi={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},Ii={allowList:fi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Di={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ni extends W{constructor(t,e){if(void 0===i)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Ii}static get DefaultType(){return Di}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ki),$i,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.eventName("show")),e=(u(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:s}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(s.append(i),j.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._queueCallback((()=>{j.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!j.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._activeTrigger.click=!1,this._activeTrigger[Li]=!1,this._activeTrigger[Si]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ci,Ti),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ci),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ai({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ci)}_isShown(){return this.tip&&this.tip.classList.contains(Ti)}_createPopper(t){const e=v(this._config.placement,[this,t,this._element]),s=Oi[e.toUpperCase()];return i.createPopper(this._element,t,this._getPopperConfig(s))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return v(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...v(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)j.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Si?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===Si?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");j.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?Li:Si]=!0,e._enter()})),j.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?Li:Si]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ki),$i,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=B.getDataAttributes(this._element);for(const t of Object.keys(e))Ei.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:c(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Ni);const Pi={...Ni.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},xi={...Ni.DefaultType,content:"(null|string|element|function)"};class Mi extends Ni{static get Default(){return Pi}static get DefaultType(){return xi}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=Mi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Mi);const ji=".bs.scrollspy",Fi=`activate${ji}`,zi=`click${ji}`,Hi=`load${ji}.data-api`,Bi="active",qi="[href]",Wi=".nav-link",Ri=`${Wi}, .nav-item > ${Wi}, .list-group-item`,Ki={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Vi={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Qi extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Ki}static get DefaultType(){return Vi}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=c(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(j.off(this._config.target,zi),j.on(this._config.target,zi,qi,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,s=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:s,behavior:"smooth"});i.scrollTop=s}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},s=(this._rootElement||document.documentElement).scrollTop,n=s>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=s;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(n&&t){if(i(o),!s)return}else n||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=K.find(qi,this._config.target);for(const e of t){if(!e.hash||d(e))continue;const t=K.findOne(decodeURI(e.hash),this._element);h(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(Bi),this._activateParents(t),j.trigger(this._element,Fi,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))K.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(Bi);else for(const e of K.parents(t,".nav, .list-group"))for(const t of K.prev(e,Ri))t.classList.add(Bi)}_clearActiveClass(t){t.classList.remove(Bi);const e=K.find(`${qi}.${Bi}`,t);for(const t of e)t.classList.remove(Bi)}static jQueryInterface(t){return this.each((function(){const e=Qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,Hi,(()=>{for(const t of K.find('[data-bs-spy="scroll"]'))Qi.getOrCreateInstance(t)})),b(Qi);const Xi=".bs.tab",Yi=`hide${Xi}`,Ui=`hidden${Xi}`,Gi=`show${Xi}`,Ji=`shown${Xi}`,Zi=`click${Xi}`,ts=`keydown${Xi}`,es=`load${Xi}`,is="ArrowLeft",ss="ArrowRight",ns="ArrowUp",os="ArrowDown",rs="Home",as="End",ls="active",cs="fade",hs="show",ds=".dropdown-toggle",us=`:not(${ds})`,_s='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',gs=`.nav-link${us}, .list-group-item${us}, [role="tab"]${us}, ${_s}`,fs=`.${ls}[data-bs-toggle="tab"], .${ls}[data-bs-toggle="pill"], .${ls}[data-bs-toggle="list"]`;class ms extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),j.on(this._element,ts,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?j.trigger(e,Yi,{relatedTarget:t}):null;j.trigger(t,Gi,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(ls),this._activate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),j.trigger(t,Ji,{relatedTarget:e})):t.classList.add(hs)}),t,t.classList.contains(cs)))}_deactivate(t,e){t&&(t.classList.remove(ls),t.blur(),this._deactivate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),j.trigger(t,Ui,{relatedTarget:e})):t.classList.remove(hs)}),t,t.classList.contains(cs)))}_keydown(t){if(![is,ss,ns,os,rs,as].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!d(t)));let i;if([rs,as].includes(t.key))i=e[t.key===rs?0:e.length-1];else{const s=[ss,os].includes(t.key);i=w(e,t.target,s,!0)}i&&(i.focus({preventScroll:!0}),ms.getOrCreateInstance(i).show())}_getChildren(){return K.find(gs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=K.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const s=(t,s)=>{const n=K.findOne(t,i);n&&n.classList.toggle(s,e)};s(ds,ls),s(".dropdown-menu",hs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(ls)}_getInnerElement(t){return t.matches(gs)?t:K.findOne(gs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=ms.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,Zi,_s,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this)||ms.getOrCreateInstance(this).show()})),j.on(window,es,(()=>{for(const t of K.find(fs))ms.getOrCreateInstance(t)})),b(ms);const ps=".bs.toast",bs=`mouseover${ps}`,vs=`mouseout${ps}`,ys=`focusin${ps}`,ws=`focusout${ps}`,As=`hide${ps}`,Es=`hidden${ps}`,Cs=`show${ps}`,Ts=`shown${ps}`,ks="hide",$s="show",Ss="showing",Ls={animation:"boolean",autohide:"boolean",delay:"number"},Os={animation:!0,autohide:!0,delay:5e3};class Is extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Os}static get DefaultType(){return Ls}static get NAME(){return"toast"}show(){j.trigger(this._element,Cs).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(ks),g(this._element),this._element.classList.add($s,Ss),this._queueCallback((()=>{this._element.classList.remove(Ss),j.trigger(this._element,Ts),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(j.trigger(this._element,As).defaultPrevented||(this._element.classList.add(Ss),this._queueCallback((()=>{this._element.classList.add(ks),this._element.classList.remove(Ss,$s),j.trigger(this._element,Es)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove($s),super.dispose()}isShown(){return this._element.classList.contains($s)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,bs,(t=>this._onInteraction(t,!0))),j.on(this._element,vs,(t=>this._onInteraction(t,!1))),j.on(this._element,ys,(t=>this._onInteraction(t,!0))),j.on(this._element,ws,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Is.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return V(Is),b(Is),{Alert:U,Button:J,Carousel:Ot,Collapse:Rt,Dropdown:fe,Modal:Ue,Offcanvas:gi,Popover:Mi,ScrollSpy:Qi,Tab:ms,Toast:Is,Tooltip:Ni}})); +//# sourceMappingURL=bootstrap.min.js.map \ No newline at end of file diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js new file mode 100644 index 0000000..fc9fe89 --- /dev/null +++ b/web/static/js/dashboard.js @@ -0,0 +1,89 @@ +const showResponse = (msg) => { + $('#responseBody').text(msg); + const toast = new bootstrap.Toast($('#responseToast')); + toast.show(); +}; + +$(document).ready(function () { + $('#change-primary-password-form').on('submit', function (event) { + event.preventDefault(); // Prevent the default form submission + + // Get the values from the input fields + const oldPassword = $('input[name="old-password-primary"]').val(); + const newPassword = $('input[name="new-password-primary"]').val(); + const newPasswordConfirm = $('input[name="new-password-confirm-primary"]').val(); + + if (oldPassword === "" || newPassword === "" || newPasswordConfirm === "") { + showResponse('Error: password empty'); + return; + } + + // Check if new password and old password are equal + if (newPassword !== newPasswordConfirm) { + showResponse('Error: passwords do not match'); + return; // Exit the function if they are not equal + } + + // Send AJAX POST request + $.ajax({ + type: 'POST', + url: '/changepassword', + data: { + old_password: oldPassword, + new_password: newPassword, + change_fsd_password: false + }, + xhrFields: { + withCredentials: true + }, + }).done(function (data, textStatus, jqXHR) { + $('input[name="old-password-primary"]').val("") + $('input[name="new-password-primary"]').val("") + $('input[name="new-password-confirm-primary"]').val("") + showResponse('Password changed successfully') + }).fail(function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nResponse: ' + jqXHR.responseText); // Alert with the status code and response body + }) + }) + + $('#change-fsd-password-form').on('submit', function (event) { + event.preventDefault(); // Prevent the default form submission + + // Get the values from the input fields + const newPassword = $('input[name="new-password-fsd"]').val(); + const newPasswordConfirm = $('input[name="new-password-confirm-fsd"]').val(); + + if (newPassword === "" || newPasswordConfirm === "") { + showResponse('Error: password empty'); + return; + } + + // Check if new password and old password are equal + if (newPassword !== newPasswordConfirm) { + showResponse('Error: passwords do not match'); + return; // Exit the function if they are not equal + } + + // Send AJAX POST request + $.ajax({ + type: 'POST', + url: '/changepassword', + data: { + new_password: newPassword, + change_fsd_password: true + }, + xhrFields: { + withCredentials: true // Include credentials (cookies, etc.) in the request + }, + }).done(function (data, textStatus, jqXHR) { + console.log('done') + $('input[name="new-password-fsd"]').val("") + $('input[name="new-password-confirm-fsd"]').val("") + showResponse('FSD password changed successfully!') + }).fail(function (jqXHR, textStatus, errorThrown) { + // If we received an error response from the server + showResponse('Error: ' + jqXHR.statusText + '\nResponse: ' + jqXHR.responseText); // Alert with the status code and response body + }) + }) +}); \ No newline at end of file diff --git a/web/static/js/jquery-3.7.1.min.js b/web/static/js/jquery-3.7.1.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/web/static/js/jquery-3.7.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0 { + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie; + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; + }); +} + +function logout() { + deleteAllCookies() + localStorage.clear() + window.location.assign('/login') +} \ No newline at end of file diff --git a/web/static/js/modal.js b/web/static/js/modal.js new file mode 100644 index 0000000..fb7c476 --- /dev/null +++ b/web/static/js/modal.js @@ -0,0 +1,9 @@ +function showModal(id) { + const m = new bootstrap.Modal(document.getElementById(id)) + m.show() +} + +function hideModal(id) { + const m = new bootstrap.Modal(document.getElementById(id)) + m.hide() +} \ No newline at end of file diff --git a/web/static/js/popper.min.js b/web/static/js/popper.min.js new file mode 100644 index 0000000..7054492 --- /dev/null +++ b/web/static/js/popper.min.js @@ -0,0 +1,6 @@ +/** + * @popperjs/core v2.10.2 - MIT License + */ + +"use strict";!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Popper={})}(this,(function(e){function t(e,t){return{width:(e=e.getBoundingClientRect()).width/1,height:e.height/1,top:e.top/1,right:e.right/1,bottom:e.bottom/1,left:e.left/1,x:e.left/1,y:e.top/1}}function n(e){return null==e?window:"[object Window]"!==e.toString()?(e=e.ownerDocument)&&e.defaultView||window:e}function o(e){return{scrollLeft:(e=n(e)).pageXOffset,scrollTop:e.pageYOffset}}function r(e){return e instanceof n(e).Element||e instanceof Element}function i(e){return e instanceof n(e).HTMLElement||e instanceof HTMLElement}function a(e){return"undefined"!=typeof ShadowRoot&&(e instanceof n(e).ShadowRoot||e instanceof ShadowRoot)}function s(e){return e?(e.nodeName||"").toLowerCase():null}function f(e){return((r(e)?e.ownerDocument:e.document)||window.document).documentElement}function p(e){return t(f(e)).left+o(e).scrollLeft}function c(e){return n(e).getComputedStyle(e)}function l(e){return e=c(e),/auto|scroll|overlay|hidden/.test(e.overflow+e.overflowY+e.overflowX)}function u(e,r,a){void 0===a&&(a=!1);var c=i(r);i(r)&&r.getBoundingClientRect();var u=f(r);e=t(e);var d={scrollLeft:0,scrollTop:0},m={x:0,y:0};return(c||!c&&!a)&&(("body"!==s(r)||l(u))&&(d=r!==n(r)&&i(r)?{scrollLeft:r.scrollLeft,scrollTop:r.scrollTop}:o(r)),i(r)?((m=t(r)).x+=r.clientLeft,m.y+=r.clientTop):u&&(m.x=p(u))),{x:e.left+d.scrollLeft-m.x,y:e.top+d.scrollTop-m.y,width:e.width,height:e.height}}function d(e){var n=t(e),o=e.offsetWidth,r=e.offsetHeight;return 1>=Math.abs(n.width-o)&&(o=n.width),1>=Math.abs(n.height-r)&&(r=n.height),{x:e.offsetLeft,y:e.offsetTop,width:o,height:r}}function m(e){return"html"===s(e)?e:e.assignedSlot||e.parentNode||(a(e)?e.host:null)||f(e)}function h(e){return 0<=["html","body","#document"].indexOf(s(e))?e.ownerDocument.body:i(e)&&l(e)?e:h(m(e))}function v(e,t){var o;void 0===t&&(t=[]);var r=h(e);return e=r===(null==(o=e.ownerDocument)?void 0:o.body),o=n(r),r=e?[o].concat(o.visualViewport||[],l(r)?r:[]):r,t=t.concat(r),e?t:t.concat(v(m(r)))}function g(e){return i(e)&&"fixed"!==c(e).position?e.offsetParent:null}function b(e){for(var t=n(e),o=g(e);o&&0<=["table","td","th"].indexOf(s(o))&&"static"===c(o).position;)o=g(o);if(o&&("html"===s(o)||"body"===s(o)&&"static"===c(o).position))return t;if(!o)e:{if(o=-1!==navigator.userAgent.toLowerCase().indexOf("firefox"),-1===navigator.userAgent.indexOf("Trident")||!i(e)||"fixed"!==c(e).position)for(e=m(e);i(e)&&0>["html","body"].indexOf(s(e));){var r=c(e);if("none"!==r.transform||"none"!==r.perspective||"paint"===r.contain||-1!==["transform","perspective"].indexOf(r.willChange)||o&&"filter"===r.willChange||o&&r.filter&&"none"!==r.filter){o=e;break e}e=e.parentNode}o=null}return o||t}function y(e){function t(e){o.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){o.has(e)||(e=n.get(e))&&t(e)})),r.push(e)}var n=new Map,o=new Set,r=[];return e.forEach((function(e){n.set(e.name,e)})),e.forEach((function(e){o.has(e.name)||t(e)})),r}function w(e){var t;return function(){return t||(t=new Promise((function(n){Promise.resolve().then((function(){t=void 0,n(e())}))}))),t}}function x(e){return e.split("-")[0]}function O(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&a(n))do{if(t&&e.isSameNode(t))return!0;t=t.parentNode||t.host}while(t);return!1}function j(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function E(e,r){if("viewport"===r){r=n(e);var a=f(e);r=r.visualViewport;var s=a.clientWidth;a=a.clientHeight;var l=0,u=0;r&&(s=r.width,a=r.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(l=r.offsetLeft,u=r.offsetTop)),e=j(e={width:s,height:a,x:l+p(e),y:u})}else i(r)?((e=t(r)).top+=r.clientTop,e.left+=r.clientLeft,e.bottom=e.top+r.clientHeight,e.right=e.left+r.clientWidth,e.width=r.clientWidth,e.height=r.clientHeight,e.x=e.left,e.y=e.top):(u=f(e),e=f(u),s=o(u),r=null==(a=u.ownerDocument)?void 0:a.body,a=U(e.scrollWidth,e.clientWidth,r?r.scrollWidth:0,r?r.clientWidth:0),l=U(e.scrollHeight,e.clientHeight,r?r.scrollHeight:0,r?r.clientHeight:0),u=-s.scrollLeft+p(u),s=-s.scrollTop,"rtl"===c(r||e).direction&&(u+=U(e.clientWidth,r?r.clientWidth:0)-a),e=j({width:a,height:l,x:u,y:s}));return e}function D(e,t,n){return t="clippingParents"===t?function(e){var t=v(m(e)),n=0<=["absolute","fixed"].indexOf(c(e).position)&&i(e)?b(e):e;return r(n)?t.filter((function(e){return r(e)&&O(e,n)&&"body"!==s(e)})):[]}(e):[].concat(t),(n=(n=[].concat(t,[n])).reduce((function(t,n){return n=E(e,n),t.top=U(n.top,t.top),t.right=z(n.right,t.right),t.bottom=z(n.bottom,t.bottom),t.left=U(n.left,t.left),t}),E(e,n[0]))).width=n.right-n.left,n.height=n.bottom-n.top,n.x=n.left,n.y=n.top,n}function L(e){return e.split("-")[1]}function P(e){return 0<=["top","bottom"].indexOf(e)?"x":"y"}function M(e){var t=e.reference,n=e.element,o=(e=e.placement)?x(e):null;e=e?L(e):null;var r=t.x+t.width/2-n.width/2,i=t.y+t.height/2-n.height/2;switch(o){case"top":r={x:r,y:t.y-n.height};break;case"bottom":r={x:r,y:t.y+t.height};break;case"right":r={x:t.x+t.width,y:i};break;case"left":r={x:t.x-n.width,y:i};break;default:r={x:t.x,y:t.y}}if(null!=(o=o?P(o):null))switch(i="y"===o?"height":"width",e){case"start":r[o]-=t[i]/2-n[i]/2;break;case"end":r[o]+=t[i]/2-n[i]/2}return r}function k(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function A(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function B(e,n){void 0===n&&(n={});var o=n;n=void 0===(n=o.placement)?e.placement:n;var i=o.boundary,a=void 0===i?"clippingParents":i,s=void 0===(i=o.rootBoundary)?"viewport":i;i=void 0===(i=o.elementContext)?"popper":i;var p=o.altBoundary,c=void 0!==p&&p;o=k("number"!=typeof(o=void 0===(o=o.padding)?0:o)?o:A(o,N)),p=e.rects.popper,a=D(r(c=e.elements[c?"popper"===i?"reference":"popper":i])?c:c.contextElement||f(e.elements.popper),a,s),c=M({reference:s=t(e.elements.reference),element:p,strategy:"absolute",placement:n}),p=j(Object.assign({},p,c)),s="popper"===i?p:s;var l={top:a.top-s.top+o.top,bottom:s.bottom-a.bottom+o.bottom,left:a.left-s.left+o.left,right:s.right-a.right+o.right};if(e=e.modifiersData.offset,"popper"===i&&e){var u=e[n];Object.keys(l).forEach((function(e){var t=0<=["right","bottom"].indexOf(e)?1:-1,n=0<=["top","bottom"].indexOf(e)?"y":"x";l[e]+=u[n]*t}))}return l}function W(){for(var e=arguments.length,t=Array(e),n=0;n=(y.devicePixelRatio||1)?"translate("+e+"px, "+d+"px)":"translate3d("+e+"px, "+d+"px, 0)",h)):Object.assign({},o,((t={})[g]=s?d+"px":"",t[v]=m?e+"px":"",t.transform="",t))}function H(e){return e.replace(/left|right|bottom|top/g,(function(e){return ee[e]}))}function S(e){return e.replace(/start|end/g,(function(e){return te[e]}))}function C(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function q(e){return["top","right","bottom","left"].some((function(t){return 0<=e[t]}))}var N=["top","bottom","right","left"],V=N.reduce((function(e,t){return e.concat([t+"-start",t+"-end"])}),[]),I=[].concat(N,["auto"]).reduce((function(e,t){return e.concat([t,t+"-start",t+"-end"])}),[]),_="beforeRead read afterRead beforeMain main afterMain beforeWrite write afterWrite".split(" "),U=Math.max,z=Math.min,F=Math.round,X={placement:"bottom",modifiers:[],strategy:"absolute"},Y={passive:!0},G={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,o=e.instance,r=(e=e.options).scroll,i=void 0===r||r,a=void 0===(e=e.resize)||e,s=n(t.elements.popper),f=[].concat(t.scrollParents.reference,t.scrollParents.popper);return i&&f.forEach((function(e){e.addEventListener("scroll",o.update,Y)})),a&&s.addEventListener("resize",o.update,Y),function(){i&&f.forEach((function(e){e.removeEventListener("scroll",o.update,Y)})),a&&s.removeEventListener("resize",o.update,Y)}},data:{}},J={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state;t.modifiersData[e.name]=M({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}},K={top:"auto",right:"auto",bottom:"auto",left:"auto"},Q={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options;e=void 0===(e=n.gpuAcceleration)||e;var o=n.adaptive;o=void 0===o||o,n=void 0===(n=n.roundOffsets)||n,e={placement:x(t.placement),variation:L(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:e},null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign({},t.styles.popper,R(Object.assign({},e,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:o,roundOffsets:n})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign({},t.styles.arrow,R(Object.assign({},e,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:n})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})},data:{}},Z={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},o=t.attributes[e]||{},r=t.elements[e];i(r)&&s(r)&&(Object.assign(r.style,n),Object.keys(o).forEach((function(e){var t=o[e];!1===t?r.removeAttribute(e):r.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var o=t.elements[e],r=t.attributes[e]||{};e=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{}),i(o)&&s(o)&&(Object.assign(o.style,e),Object.keys(r).forEach((function(e){o.removeAttribute(e)})))}))}},requires:["computeStyles"]},$={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var t=e.state,n=e.name,o=void 0===(e=e.options.offset)?[0,0]:e,r=(e=I.reduce((function(e,n){var r=t.rects,i=x(n),a=0<=["left","top"].indexOf(i)?-1:1,s="function"==typeof o?o(Object.assign({},r,{placement:n})):o;return r=(r=s[0])||0,s=((s=s[1])||0)*a,i=0<=["left","right"].indexOf(i)?{x:s,y:r}:{x:r,y:s},e[n]=i,e}),{}))[t.placement],i=r.x;r=r.y,null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=i,t.modifiersData.popperOffsets.y+=r),t.modifiersData[n]=e}},ee={left:"right",right:"left",bottom:"top",top:"bottom"},te={start:"end",end:"start"},ne={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options;if(e=e.name,!t.modifiersData[e]._skip){var o=n.mainAxis;o=void 0===o||o;var r=n.altAxis;r=void 0===r||r;var i=n.fallbackPlacements,a=n.padding,s=n.boundary,f=n.rootBoundary,p=n.altBoundary,c=n.flipVariations,l=void 0===c||c,u=n.allowedAutoPlacements;c=x(n=t.options.placement),i=i||(c!==n&&l?function(e){if("auto"===x(e))return[];var t=H(e);return[S(e),t,S(t)]}(n):[H(n)]);var d=[n].concat(i).reduce((function(e,n){return e.concat("auto"===x(n)?function(e,t){void 0===t&&(t={});var n=t.boundary,o=t.rootBoundary,r=t.padding,i=t.flipVariations,a=t.allowedAutoPlacements,s=void 0===a?I:a,f=L(t.placement);0===(i=(t=f?i?V:V.filter((function(e){return L(e)===f})):N).filter((function(e){return 0<=s.indexOf(e)}))).length&&(i=t);var p=i.reduce((function(t,i){return t[i]=B(e,{placement:i,boundary:n,rootBoundary:o,padding:r})[x(i)],t}),{});return Object.keys(p).sort((function(e,t){return p[e]-p[t]}))}(t,{placement:n,boundary:s,rootBoundary:f,padding:a,flipVariations:l,allowedAutoPlacements:u}):n)}),[]);n=t.rects.reference,i=t.rects.popper;var m=new Map;c=!0;for(var h=d[0],v=0;vi[O]&&(y=H(y)),O=H(y),w=[],o&&w.push(0>=j[b]),r&&w.push(0>=j[y],0>=j[O]),w.every((function(e){return e}))){h=g,c=!1;break}m.set(g,w)}if(c)for(o=function(e){var t=d.find((function(t){if(t=m.get(t))return t.slice(0,e).every((function(e){return e}))}));if(t)return h=t,"break"},r=l?3:1;0 + + + + + + + + +
+
+
+
+
+ User +
+
+
+ + +
+
+
+
+
+
+ + + +{{ end }} \ No newline at end of file diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..a5db930 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,166 @@ +{{ template "layout.html" . }} + +{{ define "title" }} Dashboard {{ end }} + +{{ define "content" }} + + + + +
+
+
+ Your Account: +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CID
{{ .UserRecord.CID }}
Email
{{ .UserRecord.Email }}
First Name
{{ .UserRecord.FirstName }}
Last Name
{{ .UserRecord.LastName }}
Rating
{{ .UserRecord.NetworkRating.String }}
Password (primary) ?
Password (FSD) ? + +
+
+ + {{ if .UserRecord.NetworkRating.IsSupervisorOrAbove }} +
+ +
+ {{ end }} +
+
+ + + + + + + +{{ end }} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..b0f4a44 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,33 @@ + + + + + + openfsd - {{block "title" .}}{{end}} + + + + + + +
+
+
+
+ {{block "title" .}}{{end}} +
+
+ openfsd + + + + + + +
+
+
+
+
{{block "content" . }}{{end}}
+ + \ No newline at end of file diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..9ce0a20 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,37 @@ +{{ template "layout.html" . }} + +{{ define "title" }} Login {{ end }} + +{{ define "content" }} +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ + +
+{{ end }} \ No newline at end of file diff --git a/web/users.go b/web/users.go new file mode 100644 index 0000000..1e4f85f --- /dev/null +++ b/web/users.go @@ -0,0 +1,280 @@ +package web + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "github.com/golang-jwt/jwt/v5" + auth2 "github.com/renorris/openfsd/auth" + "github.com/renorris/openfsd/database" + "github.com/renorris/openfsd/protocol" + "github.com/renorris/openfsd/servercontext" + "io" + "net/http" + "path" + "slices" + "strconv" + "strings" +) + +type APIV1UsersRequest struct { + CID int `json:"cid,omitempty"` + User database.FSDUserRecord `json:"user,omitempty"` +} + +type APIV1UsersResponse struct { + StatusMessage string `json:"msg"` + User *database.FSDUserRecord `json:"user,omitempty"` +} + +// APIV1UsersHandler handles all /api/v1/users calls +func APIV1UsersHandler(w http.ResponseWriter, r *http.Request, verifier auth2.JWTVerifier) { + r.Body = http.MaxBytesReader(w, r.Body, 8192) + + w.Header().Set("Content-Type", "application/json") + resp := APIV1UsersResponse{} + + // Verify authorization + var tokenStr string + if tokenStr = r.Header.Get("Authorization"); tokenStr == "" { + resp.StatusMessage = "authorization header missing" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + if split := strings.Split(tokenStr, "Bearer "); len(split) != 2 { + resp.StatusMessage = "invalid authorization header format" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } else { + tokenStr = split[1] + } + + // Verify JWT signature and expiry times + var token *jwt.Token + var err error + if token, err = verifier.VerifyJWT(tokenStr); err != nil { + resp.StatusMessage = "invalid token" + writeResponseError(w, http.StatusForbidden, &resp) + return + } + + // Verify JWT claims + claims := auth2.FSDJWTClaims{} + if err = claims.Parse(token); err != nil { + resp.StatusMessage = "invalid token claims" + writeResponseError(w, http.StatusForbidden, &resp) + return + } + + if !slices.Contains(claims.Audience(), "dashboard") { + resp.StatusMessage = "invalid token audience" + writeResponseError(w, http.StatusForbidden, &resp) + return + } + + // Read body + var body []byte + if body, err = io.ReadAll(r.Body); err != nil { + resp.StatusMessage = "error reading request body" + writeResponseError(w, http.StatusInternalServerError, &resp) + return + } + + var status int + req := APIV1UsersRequest{} + // GET method doesn't have a body, handle it separately + if r.Method == "GET" { + if !strings.HasPrefix(r.URL.Path, "/api/v1/users/") { + resp.StatusMessage = "invalid request path" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + var cid int + if cid, err = strconv.Atoi(path.Base(r.URL.Path)); err != nil { + resp.StatusMessage = "invalid CID" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + req.CID = cid + status = getUserHandler(&claims, &req, &resp) + } else { + if err = json.Unmarshal(body, &req); err != nil { + resp.StatusMessage = "error parsing request body" + writeResponseError(w, http.StatusBadRequest, &resp) + return + } + + switch r.Method { + case "POST": + status = createUserHandler(&claims, &req, &resp) + case "PUT": + status = updateUserHandler(&claims, &req, &resp) + case "DELETE": + status = deleteUserHandler(&claims, &req, &resp) + default: + resp.StatusMessage = "method not allowed" + writeResponseError(w, http.StatusMethodNotAllowed, &resp) + return + } + } + + // Serialize response + var resBody []byte + if resBody, err = json.Marshal(&resp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp.StatusMessage = "error serializing response body" + if respBytes, err := json.Marshal(&resp); err == nil { + io.Copy(w, bytes.NewReader(respBytes)) + } + return + } + + w.WriteHeader(status) + io.Copy(w, bytes.NewReader(resBody)) +} + +func createUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) { + // User must be an administrator, or a supervisor with the limitation of only creating users of lower rating + if claims.ControllerRating() != protocol.NetworkRatingADM { + if claims.ControllerRating() < protocol.NetworkRatingSUP { + res.StatusMessage = "must be at least Supervisor to create user" + return http.StatusForbidden + } + if req.User.NetworkRating >= protocol.NetworkRatingSUP { + res.StatusMessage = "created user must be below supervisor rating" + return http.StatusForbidden + } + } + + var err error + var autoPassword, fsdAutoPassword bool + if req.User.Password == "" { + if req.User.Password, err = generateRandomPassword(); err != nil { + res.StatusMessage = "error generating random password" + return http.StatusInternalServerError + } + autoPassword = true + } + if req.User.FSDPassword == "" { + if req.User.FSDPassword, err = generateRandomPassword(); err != nil { + res.StatusMessage = "error generating random password" + return http.StatusInternalServerError + } + fsdAutoPassword = true + } + + if req.User.CID, err = req.User.Insert(servercontext.DB()); err != nil { + res.StatusMessage = "error inserting user into database" + return http.StatusInternalServerError + } + + res.StatusMessage = "success" + + // omit passwords for response if they weren't auto generated + if !autoPassword { + req.User.Password = "" + } + if !fsdAutoPassword { + req.User.FSDPassword = "" + } + + // copy user into response + res.User = &req.User + + return http.StatusOK +} + +func getUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) { + if claims.ControllerRating() < protocol.NetworkRatingSUP { + res.StatusMessage = "must be at least Supervisor to read users" + return http.StatusForbidden + } + + userRecord := database.FSDUserRecord{} + if err := userRecord.LoadByCID(servercontext.DB(), req.CID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + res.StatusMessage = "no user found" + return http.StatusNotFound + } + + res.StatusMessage = "error loading user from database" + return http.StatusInternalServerError + } + + // omit passwords + userRecord.Password = "" + userRecord.FSDPassword = "" + + res.StatusMessage = "success" + + // copy into response + res.User = &userRecord + + return http.StatusOK +} + +func updateUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) { + // User must be an administrator, or a supervisor with the limitation of only updating users of lower rating + if claims.ControllerRating() != protocol.NetworkRatingADM { + if claims.ControllerRating() < protocol.NetworkRatingSUP { + res.StatusMessage = "must be at least Supervisor to update user" + return http.StatusForbidden + } + if req.User.NetworkRating >= protocol.NetworkRatingSUP { + res.StatusMessage = "user to update must be below supervisor rating" + return http.StatusForbidden + } + } + + var err error + if err = req.User.Update(servercontext.DB()); err != nil { + if errors.Is(err, database.NoRowsChangedError) { + res.StatusMessage = "user not found" + return http.StatusNotFound + } + res.StatusMessage = "error updating user" + return http.StatusInternalServerError + } + + if err = req.User.LoadByCID(servercontext.DB(), req.User.CID); err != nil { + res.StatusMessage = "error loading user for response" + return http.StatusInternalServerError + } + + res.StatusMessage = "success" + + // omit passwords for response + req.User.Password = "" + req.User.FSDPassword = "" + + // copy user into response + res.User = &req.User + + return http.StatusOK +} + +func deleteUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) { + // User must be an administrator + if claims.ControllerRating() != protocol.NetworkRatingADM { + res.StatusMessage = "must be at Administrator to delete user" + return http.StatusForbidden + } + + var err error + if err = req.User.Delete(servercontext.DB(), req.CID); err != nil { + if errors.Is(err, database.NoRowsChangedError) { + res.StatusMessage = "user not found" + return http.StatusNotFound + } + res.StatusMessage = "error deleting user" + return http.StatusInternalServerError + } + + res.StatusMessage = "success" + res.User = nil + + return http.StatusOK +} diff --git a/web/util.go b/web/util.go new file mode 100644 index 0000000..cc19a59 --- /dev/null +++ b/web/util.go @@ -0,0 +1,26 @@ +package web + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "net/http" +) + +func writeResponseError(w http.ResponseWriter, status int, resp any) { + w.WriteHeader(status) + if respBytes, err := json.Marshal(&resp); err == nil { + io.Copy(w, bytes.NewReader(respBytes)) + } +} + +func generateRandomPassword() (string, error) { + randBytes := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, randBytes); err != nil { + return "", err + } + + return hex.EncodeToString(randBytes), nil +}