From 8e0419c56b1a5b097a1c88f25d38d7d992f856c9 Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Wed, 18 Feb 2026 02:17:13 +0300 Subject: [PATCH] init --- auth/cookies.go | 45 ++++ auth/jwt.go | 48 ++++ docs/api/auth/folder.bru | 8 + docs/api/auth/login user.bru | 23 ++ docs/api/auth/logout user.bru | 15 ++ docs/api/auth/register new user.bru | 24 ++ docs/api/bruno.json | 9 + docs/api/environments/dev.bru | 3 + docs/api/user/folder.bru | 8 + docs/api/user/get user.bru | 15 ++ docs/api/wishlists/create wishlist.bru | 23 ++ docs/api/wishlists/delete user wishlist.bru | 20 ++ docs/api/wishlists/folder.bru | 8 + docs/api/wishlists/get user wishlists.bru | 16 ++ docs/api/wishlists/get wishlist.bru | 20 ++ go.mod | 18 ++ go.sum | 18 ++ main.go | 239 ++++++++++++++++++++ model/model.go | 10 + model/user.go | 9 + model/wishlist.go | 14 ++ router/router.go | 49 ++++ sqlite.db | Bin 0 -> 32768 bytes 23 files changed, 642 insertions(+) create mode 100644 auth/cookies.go create mode 100644 auth/jwt.go create mode 100644 docs/api/auth/folder.bru create mode 100644 docs/api/auth/login user.bru create mode 100644 docs/api/auth/logout user.bru create mode 100644 docs/api/auth/register new user.bru create mode 100644 docs/api/bruno.json create mode 100644 docs/api/environments/dev.bru create mode 100644 docs/api/user/folder.bru create mode 100644 docs/api/user/get user.bru create mode 100644 docs/api/wishlists/create wishlist.bru create mode 100644 docs/api/wishlists/delete user wishlist.bru create mode 100644 docs/api/wishlists/folder.bru create mode 100644 docs/api/wishlists/get user wishlists.bru create mode 100644 docs/api/wishlists/get wishlist.bru create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/model.go create mode 100644 model/user.go create mode 100644 model/wishlist.go create mode 100644 router/router.go create mode 100644 sqlite.db diff --git a/auth/cookies.go b/auth/cookies.go new file mode 100644 index 0000000..a158cb3 --- /dev/null +++ b/auth/cookies.go @@ -0,0 +1,45 @@ +package auth + +import ( + "fmt" + "net/http" + "time" +) + +func SetUserCookie(w http.ResponseWriter, token string, expiryTime time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: token, + Secure: true, + HttpOnly: true, + Path: "/", + Expires: expiryTime, + SameSite: http.SameSiteStrictMode, + }) +} + +func RemoveUserCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: "", + Secure: true, + HttpOnly: true, + Path: "/", + Expires: time.Now().Add(-time.Hour), + SameSite: http.SameSiteStrictMode, + }) +} + +func GetUserIdFromRequest(r *http.Request) (uint, error) { + c, err := r.Cookie("token") + if err != nil { + return 0, fmt.Errorf("no token cookie: %v", err) + } + + userId, err := ValidateUserToken(c.Value) + if err != nil { + return 0, fmt.Errorf("invalid token: %v", err) + } + + return userId, nil +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..fa62399 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,48 @@ +package auth + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = os.Getenv("JWT_SECRET_KEY") + +type UserClaims struct { + jwt.RegisteredClaims + UserID uint +} + +func GenerateUserToken(userId uint, expiryTime time.Time) (string, error) { + now := time.Now() + claims := UserClaims{ + UserID: userId, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiryTime), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString([]byte(secretKey)) +} + +func ValidateUserToken(token string) (uint, error) { + claims := &UserClaims{} + + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + return []byte(secretKey), nil + }) + if err != nil { + return 0, fmt.Errorf("failed to parse token: %v", err) + } + + if !parsed.Valid { + return 0, fmt.Errorf("invalid token") + } + + return claims.UserID, nil +} diff --git a/docs/api/auth/folder.bru b/docs/api/auth/folder.bru new file mode 100644 index 0000000..8fe9662 --- /dev/null +++ b/docs/api/auth/folder.bru @@ -0,0 +1,8 @@ +meta { + name: auth + seq: 1 +} + +auth { + mode: inherit +} diff --git a/docs/api/auth/login user.bru b/docs/api/auth/login user.bru new file mode 100644 index 0000000..5051c57 --- /dev/null +++ b/docs/api/auth/login user.bru @@ -0,0 +1,23 @@ +meta { + name: login user + type: http + seq: 2 +} + +post { + url: {{base_url}}/auth/login + body: json + auth: inherit +} + +body:json { + { + "login": "tsivinsky", + "password": "Daniil2000" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/auth/logout user.bru b/docs/api/auth/logout user.bru new file mode 100644 index 0000000..09d4f13 --- /dev/null +++ b/docs/api/auth/logout user.bru @@ -0,0 +1,15 @@ +meta { + name: logout user + type: http + seq: 3 +} + +post { + url: {{base_url}}/auth/logout + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/docs/api/auth/register new user.bru b/docs/api/auth/register new user.bru new file mode 100644 index 0000000..a9980cb --- /dev/null +++ b/docs/api/auth/register new user.bru @@ -0,0 +1,24 @@ +meta { + name: register new user + type: http + seq: 1 +} + +post { + url: {{base_url}}/auth/register + body: json + auth: inherit +} + +body:json { + { + "email": "daniil@tsivinsky.com", + "login": "tsivinsky", + "password": "Daniil2000" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/bruno.json b/docs/api/bruno.json new file mode 100644 index 0000000..261fad1 --- /dev/null +++ b/docs/api/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "wishlify", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/docs/api/environments/dev.bru b/docs/api/environments/dev.bru new file mode 100644 index 0000000..a7ecabd --- /dev/null +++ b/docs/api/environments/dev.bru @@ -0,0 +1,3 @@ +vars { + base_url: http://localhost:5000 +} diff --git a/docs/api/user/folder.bru b/docs/api/user/folder.bru new file mode 100644 index 0000000..353be05 --- /dev/null +++ b/docs/api/user/folder.bru @@ -0,0 +1,8 @@ +meta { + name: user + seq: 2 +} + +auth { + mode: inherit +} diff --git a/docs/api/user/get user.bru b/docs/api/user/get user.bru new file mode 100644 index 0000000..33e5bbf --- /dev/null +++ b/docs/api/user/get user.bru @@ -0,0 +1,15 @@ +meta { + name: get user + type: http + seq: 1 +} + +get { + url: {{base_url}}/user + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/docs/api/wishlists/create wishlist.bru b/docs/api/wishlists/create wishlist.bru new file mode 100644 index 0000000..cec1643 --- /dev/null +++ b/docs/api/wishlists/create wishlist.bru @@ -0,0 +1,23 @@ +meta { + name: create wishlist + type: http + seq: 2 +} + +post { + url: {{base_url}}/user/wishlists + body: json + auth: inherit +} + +body:json { + { + "name": "test2", + "description": "" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/wishlists/delete user wishlist.bru b/docs/api/wishlists/delete user wishlist.bru new file mode 100644 index 0000000..d8ace31 --- /dev/null +++ b/docs/api/wishlists/delete user wishlist.bru @@ -0,0 +1,20 @@ +meta { + name: delete user wishlist + type: http + seq: 3 +} + +delete { + url: {{base_url}}/user/wishlists/:uid + body: none + auth: inherit +} + +params:path { + uid: 019c6dd3-1832-75c1-8955-d545efeb3474 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/wishlists/folder.bru b/docs/api/wishlists/folder.bru new file mode 100644 index 0000000..256e096 --- /dev/null +++ b/docs/api/wishlists/folder.bru @@ -0,0 +1,8 @@ +meta { + name: wishlists + seq: 3 +} + +auth { + mode: inherit +} diff --git a/docs/api/wishlists/get user wishlists.bru b/docs/api/wishlists/get user wishlists.bru new file mode 100644 index 0000000..57848e5 --- /dev/null +++ b/docs/api/wishlists/get user wishlists.bru @@ -0,0 +1,16 @@ +meta { + name: get user wishlists + type: http + seq: 1 +} + +get { + url: {{base_url}}/user/wishlists + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/docs/api/wishlists/get wishlist.bru b/docs/api/wishlists/get wishlist.bru new file mode 100644 index 0000000..73e3214 --- /dev/null +++ b/docs/api/wishlists/get wishlist.bru @@ -0,0 +1,20 @@ +meta { + name: get wishlist + type: http + seq: 4 +} + +get { + url: {{base_url}}/wishlists/:uid + body: none + auth: inherit +} + +params:path { + uid: 019c6dcd-fde9-7374-a731-f03af290ea85 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..00cb1b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module wishlify + +go 1.25.7 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.48.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c3b395a --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7866be8 --- /dev/null +++ b/main.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + "wishlify/auth" + "wishlify/model" + "wishlify/router" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./sqlite.db" + } + + db, err := gorm.Open(sqlite.Open("./sqlite.db")) + if err != nil { + log.Fatalf("failed to connect to db: %v\n", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.Wishlist{}); err != nil { + log.Fatalf("failed to migrate db: %v\n", err) + } + + router := router.New() + + router.Handle("POST /auth/register", func(w http.ResponseWriter, r *http.Request) error { + var body struct { + Email string `json:"email"` + Login string `json:"login"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to decode request body: %v", err) + } + + if body.Email == "" || body.Login == "" || body.Password == "" { + w.WriteHeader(400) + return fmt.Errorf("invalid request; email, login and password are required") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10) + if err != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to generate password hash: %v", err) + } + + user := &model.User{ + Email: body.Email, + Login: body.Login, + Password: string(hash), + } + if tx := db.Create(user); tx.Error != nil { + w.WriteHeader(400) + return fmt.Errorf("failed to create user: %v", tx.Error) + } + + expiryTime := time.Now().Add(time.Hour * 24 * 7) + token, err := auth.GenerateUserToken(user.ID, expiryTime) + if err != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to generate jwt: %v", err) + } + + auth.SetUserCookie(w, token, expiryTime) + + w.WriteHeader(201) + return router.SendJSON(w, user) + }) + + router.Handle("POST /auth/login", func(w http.ResponseWriter, r *http.Request) error { + var body struct { + Login string `json:"login"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(400) + return fmt.Errorf("failed to decode request body: %v", err) + } + + if body.Login == "" || body.Password == "" { + w.WriteHeader(400) + return fmt.Errorf("invalid request; login and password are required") + } + + user := &model.User{} + if tx := db.First(user, "login = ?", body.Login); tx.Error != nil { + w.WriteHeader(404) + return fmt.Errorf("user not found: %v", tx.Error) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(body.Password)); err != nil { + w.WriteHeader(400) + return fmt.Errorf("invalid password: %v", err) + } + + expiryTime := time.Now().Add(time.Hour * 24 * 7) + token, err := auth.GenerateUserToken(user.ID, expiryTime) + if err != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to generate jwt: %v", err) + } + + auth.SetUserCookie(w, token, expiryTime) + + w.WriteHeader(200) + return router.SendJSON(w, user) + }) + + router.Handle("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) error { + auth.RemoveUserCookie(w) + w.WriteHeader(200) + return router.SendJSON(w, struct { + Ok bool `json:"ok"` + }{true}) + }) + + router.Handle("GET /user", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + w.WriteHeader(401) + return fmt.Errorf("unauthorized: %v", err) + } + + user := &model.User{} + if tx := db.First(user, "id = ?", userId); tx.Error != nil { + w.WriteHeader(404) + return fmt.Errorf("user not found: %v", tx.Error) + } + + return router.SendJSON(w, user) + }) + + router.Handle("GET /user/wishlists", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + w.WriteHeader(401) + return fmt.Errorf("unauthorized: %v", err) + } + + wishlists := []model.Wishlist{} + if tx := db.Find(&wishlists, "user_id = ?", userId); tx.Error != nil { + w.WriteHeader(404) + return fmt.Errorf("no wishlists found: %v", err) + } + + w.WriteHeader(200) + return router.SendJSON(w, wishlists) + }) + + router.Handle("POST /user/wishlists", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + w.WriteHeader(401) + return fmt.Errorf("unauthorized: %v", err) + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(400) + return fmt.Errorf("failed to decode request body: %v", err) + } + + if body.Name == "" { + w.WriteHeader(400) + return fmt.Errorf("invalid request; name is required") + } + + uid, err := uuid.NewV7() + if err != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to generate uuid for new wishlist: %v", err) + } + + wishlist := &model.Wishlist{ + UUID: uid.String(), + Name: body.Name, + Description: body.Description, + UserID: userId, + } + if tx := db.Create(wishlist); tx.Error != nil { + w.WriteHeader(500) + return fmt.Errorf("failed to create wishlist: %v", tx.Error) + } + + return router.SendJSON(w, wishlist) + }) + + router.Handle("GET /wishlists/{uid}", func(w http.ResponseWriter, r *http.Request) error { + uid := r.PathValue("uid") + + wishlist := &model.Wishlist{} + if tx := db.First(wishlist, "uuid = ?", uid); tx.Error != nil { + w.WriteHeader(404) + return fmt.Errorf("wishlist not found: %v", tx.Error) + } + + w.WriteHeader(200) + return router.SendJSON(w, wishlist) + }) + + router.Handle("DELETE /user/wishlists/{uid}", func(w http.ResponseWriter, r *http.Request) error { + userId, err := auth.GetUserIdFromRequest(r) + if err != nil { + w.WriteHeader(401) + return fmt.Errorf("unauthorized: %v", err) + } + + uid := r.PathValue("uid") + + if tx := db.Delete(&model.Wishlist{}, "uuid = ? AND user_id = ?", uid, userId); tx.Error != nil { + w.WriteHeader(404) + return fmt.Errorf("failed to delete wishlist: %v", tx.Error) + } + + return router.SendJSON(w, struct { + Ok bool `json:"ok"` + }{true}) + }) + + log.Println("starting http server") + if err := http.ListenAndServe(":5000", router.Mux()); err != nil { + log.Fatalf("failed to start http server: %v\n", err) + } +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..9b0fc48 --- /dev/null +++ b/model/model.go @@ -0,0 +1,10 @@ +package model + +import "time" + +type Model struct { + ID uint `json:"id" gorm:"primarykey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt *time.Time `json:"deletedAt" gorm:"index"` +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..4326ce9 --- /dev/null +++ b/model/user.go @@ -0,0 +1,9 @@ +package model + +type User struct { + Model + + Email string `json:"email" gorm:"unique"` + Login string `json:"login" gorm:"unique"` + Password string `json:"-"` +} diff --git a/model/wishlist.go b/model/wishlist.go new file mode 100644 index 0000000..3411248 --- /dev/null +++ b/model/wishlist.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type Wishlist struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Description string `json:"description"` + UserID uint `json:"userId"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt *time.Time `json:"deletedAt" gorm:"index"` +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..f07b90e --- /dev/null +++ b/router/router.go @@ -0,0 +1,49 @@ +package router + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type router struct { + mux *http.ServeMux +} + +func (router *router) Mux() *http.ServeMux { + return router.mux +} + +func (router *router) MsgError(msg string, err error) string { + m := msg + if err != nil { + m = fmt.Sprintf("%s: %v", msg, err) + } + return m +} + +func (router *router) SendJSON(w http.ResponseWriter, data any) error { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + return fmt.Errorf("something bad happened: %v", err) + } + return nil +} + +func (router *router) Handle(pattern string, handler func(w http.ResponseWriter, r *http.Request) error) { + router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + if err := handler(w, r); err != nil { + router.SendJSON(w, struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) + } + }) +} + +func New() *router { + return &router{ + mux: http.NewServeMux(), + } +} diff --git a/sqlite.db b/sqlite.db new file mode 100644 index 0000000000000000000000000000000000000000..302e3c062eea67e5d8ecba0e2d3faf36da24ea19 GIT binary patch literal 32768 zcmeI*&u-#I90zbaBmt@rs!A(gDlvyh4GK_WV~mkjUAoJGZ9)?MY&X4N2M@_?z$Vxx z>5*+OeTg3WKKls0_rB7$hdtDo;IM?YyXpz)JF>&f@Egy}XOx368k(nv%%!Afj|PTI zWbSj0=eZ|@a2&VepE3WOr%gXIJ?Dv^Xa41Nhs%wBNhJT`qC3BGyFVr0B!An@C*CB! z@QX+gfB*y_009U<00Izz00b5UM$xG7IK@vMG0UX4%)IT~Fz2$*9M|cXv`<}Xb__SH zi#=^rE3GQ2)t^;g68)dzb#h!Mde}gc=chtacgI-QPe3y%e!3)b`w(ePY5DBtxY z1s!gtH~u>`AB0Byf3o1-KH+EhWSjF3BnUtN0uX=z1Rwwb2tWV=5P$##K1E=%&&RoD zlT%WYElHG$Sy9U7OGM0<6{##rIaQL1nkcE#fhd{&r5?}Qp!tDP4Eth<|<`cy5 zApijgKmY;|fB*y_009U<00IzL6xfNR;^B7(X5asR$0gq_h9D0C2tWV=5P$##AOHaf zKmY;|fWZ0-#Q2C14>kgrJ^z2tCEu_AtS|!zKmY;|fB*y_009U<00Izz00bTiL?T># z_Wb_^mwd7N`@;y-KmY;|fB*y_009U<00Izz00h=R;ASJ%77}rPCq6N+b&IAc``hrz zQdU*E`D{s3l&q-~6xyTbazQP))N!Syz4+9!Qp~BPVnLI&LUHAGeXBwyU-7Z_Bm2JR zu9@wbRLiQeTF4rzoX_?|+2~1{NR5&*?b*F#4)G7e|MdvRCG%gK$Pvr--?A>qz#dWJ z+QgxhT+$I;8mUy4q@1j(vaCo2ZRJQ;h1@^?PyV?ED~cgP00Izz00bZa0SG_<0uX=z R1R(J70-F(z-