From 0a38442d88386490640b1fcc6211b3ffdcf83c73 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 8 May 2026 12:08:53 +0200 Subject: [PATCH] Build go-backend auth app with Podman dev flow --- .gitignore | 3 + .../plans/2026-05-07-go-backend-login.md | 209 ++++ .../2026-05-07-go-backend-login-design.md | 142 +++ go-backend/.air.toml | 27 + go-backend/.env.example | 1 + go-backend/README.md | 43 + go-backend/compose.yaml | 23 + go-backend/go.mod | 64 ++ go-backend/go.sum | 192 ++++ go-backend/internal/db/queries.sql | 29 + go-backend/internal/db/repository.go | 102 ++ go-backend/internal/db/schema.sql | 42 + go-backend/internal/db/seed.sql | 16 + go-backend/internal/db/sqlc/db.go | 32 + go-backend/internal/db/sqlc/models.go | 27 + go-backend/internal/db/sqlc/querier.go | 19 + go-backend/internal/db/sqlc/queries.sql.go | 99 ++ go-backend/internal/web/handlers/auth.go | 396 +++++++ go-backend/internal/web/handlers/auth_test.go | 120 +++ go-backend/internal/web/views/login.templ | 219 ++++ go-backend/internal/web/views/login_templ.go | 342 ++++++ go-backend/justfile | 61 ++ {go_backend_deprecated => go-backend}/main.go | 0 go-backend/router.go | 56 + go-backend/router_test.go | 254 +++++ go-backend/sqlc.yaml | 28 + go-backend/static/logo_dark.png | Bin 0 -> 85535 bytes go-backend/static/logo_white.png | Bin 0 -> 4562 bytes go-backend/static/manifest.webmanifest | 19 + .../pwa-icons/apple-touch-icon-180x180.png | Bin 0 -> 12129 bytes go-backend/static/pwa-icons/favicon-16x16.png | Bin 0 -> 600 bytes go-backend/static/pwa-icons/favicon-32x32.png | Bin 0 -> 1498 bytes go-backend/static/styles.css | 993 ++++++++++++++++++ go-backend/tools.go | 5 + go_backend_deprecated/.air.toml | 52 - go_backend_deprecated/Dockerfile | 30 - go_backend_deprecated/go.mod | 14 - go_backend_deprecated/go.sum | 24 - .../internal/web/handlers/login.go | 48 - .../internal/web/views/login.templ | 95 -- .../internal/web/views/login_templ.go | 102 -- go_backend_deprecated/router.go | 20 - go_backend_deprecated/router_test.go | 74 -- go_backend_deprecated/static/styles.css | 417 -------- 44 files changed, 3563 insertions(+), 876 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-07-go-backend-login.md create mode 100644 docs/superpowers/specs/2026-05-07-go-backend-login-design.md create mode 100644 go-backend/.air.toml create mode 100644 go-backend/.env.example create mode 100644 go-backend/README.md create mode 100644 go-backend/compose.yaml create mode 100644 go-backend/go.mod create mode 100644 go-backend/go.sum create mode 100644 go-backend/internal/db/queries.sql create mode 100644 go-backend/internal/db/repository.go create mode 100644 go-backend/internal/db/schema.sql create mode 100644 go-backend/internal/db/seed.sql create mode 100644 go-backend/internal/db/sqlc/db.go create mode 100644 go-backend/internal/db/sqlc/models.go create mode 100644 go-backend/internal/db/sqlc/querier.go create mode 100644 go-backend/internal/db/sqlc/queries.sql.go create mode 100644 go-backend/internal/web/handlers/auth.go create mode 100644 go-backend/internal/web/handlers/auth_test.go create mode 100644 go-backend/internal/web/views/login.templ create mode 100644 go-backend/internal/web/views/login_templ.go create mode 100644 go-backend/justfile rename {go_backend_deprecated => go-backend}/main.go (100%) create mode 100644 go-backend/router.go create mode 100644 go-backend/router_test.go create mode 100644 go-backend/sqlc.yaml create mode 100644 go-backend/static/logo_dark.png create mode 100644 go-backend/static/logo_white.png create mode 100644 go-backend/static/manifest.webmanifest create mode 100644 go-backend/static/pwa-icons/apple-touch-icon-180x180.png create mode 100644 go-backend/static/pwa-icons/favicon-16x16.png create mode 100644 go-backend/static/pwa-icons/favicon-32x32.png create mode 100644 go-backend/static/styles.css create mode 100644 go-backend/tools.go delete mode 100644 go_backend_deprecated/.air.toml delete mode 100644 go_backend_deprecated/Dockerfile delete mode 100644 go_backend_deprecated/go.mod delete mode 100644 go_backend_deprecated/go.sum delete mode 100644 go_backend_deprecated/internal/web/handlers/login.go delete mode 100644 go_backend_deprecated/internal/web/views/login.templ delete mode 100644 go_backend_deprecated/internal/web/views/login_templ.go delete mode 100644 go_backend_deprecated/router.go delete mode 100644 go_backend_deprecated/router_test.go delete mode 100644 go_backend_deprecated/static/styles.css diff --git a/.gitignore b/.gitignore index 3cfb0b0..22325c5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ dist # Supabase supabase/.temp supabase/.branches + +# Podman +.podman-compose diff --git a/docs/superpowers/plans/2026-05-07-go-backend-login.md b/docs/superpowers/plans/2026-05-07-go-backend-login.md new file mode 100644 index 0000000..e748f2d --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-go-backend-login.md @@ -0,0 +1,209 @@ +# Go Backend Login Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current Vite-oriented `go_backend` app with a standalone Go web app that serves a templ-rendered XTablo login page and uses HTMX for inline login feedback on `http://localhost:3000`. + +**Architecture:** The server remains a small `chi` application. `GET /` renders a full page from `templ`, `POST /login` returns a small server-rendered fragment for HTMX swaps, and `GET /static/*` serves local CSS. The page includes the HTMX runtime from the specified CDN URL and no custom app JavaScript. + +**Tech Stack:** Go, chi, templ, net/http, HTMX CDN, server-served CSS + +--- + +## Chunk 1: Test Scaffold And Module Setup + +### Task 1: Add dependencies and basic file layout + +**Files:** +- Modify: `go_backend/go.mod` +- Modify: `go_backend/go.sum` +- Create: `go_backend/internal/web/handlers/login.go` +- Create: `go_backend/internal/web/views/layout.templ` +- Create: `go_backend/internal/web/views/login.templ` +- Create: `go_backend/static/styles.css` + +- [ ] **Step 1: Add the failing tests first before any production behavior changes** +- [ ] **Step 2: Add `templ` module dependency after the tests are in place** +- [ ] **Step 3: Create focused directories for handlers, views, and static assets** +- [ ] **Step 4: Keep Vite-only files untouched until replacement code is ready** + +## Chunk 2: HTTP-Level TDD + +### Task 2: Cover the root page + +**Files:** +- Create: `go_backend/router_test.go` +- Test: `go_backend/router_test.go` + +- [ ] **Step 1: Write a failing test for `GET /`** + +```go +func TestRootRendersLoginPage(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + "Se connecter a Xtablo", + `hx-post="/login"`, + "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./...` +Expected: the new root-page assertions fail against the current Vite/Spa output. + +- [ ] **Step 3: Implement the minimum root-page rendering path** +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./...` +Expected: `TestRootRendersLoginPage` passes. + +### Task 3: Cover HTMX login fragment behavior + +**Files:** +- Modify: `go_backend/router_test.go` +- Test: `go_backend/router_test.go` + +- [ ] **Step 1: Write a failing test for missing login fields** + +```go +func TestLoginReturnsValidationError(t *testing.T) { + form := url.Values{} + form.Set("email", "") + form.Set("password", "") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { + t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./...` +Expected: missing route or wrong response. + +- [ ] **Step 3: Write a failing test for demo credential success** + +```go +func TestLoginReturnsSuccessMessage(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Connexion reussie") { + t.Fatalf("expected success fragment, got %q", rec.Body.String()) + } +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `go test ./...` +Expected: no success behavior exists yet. + +- [ ] **Step 5: Implement the minimal `POST /login` validation and fragment rendering** +- [ ] **Step 6: Run test to verify both login fragment tests pass** + +Run: `go test ./...` +Expected: both login tests pass. + +## Chunk 3: Replace Vite-Oriented Production Code + +### Task 4: Simplify routing and startup + +**Files:** +- Modify: `go_backend/main.go` +- Modify: `go_backend/router.go` +- Possibly remove references from: `go_backend/internal/spahandler/handler.go` +- Possibly remove references from: `go_backend/internal/frontend/file.go` + +- [ ] **Step 1: Introduce a `newRouter()` function shared by tests and main** +- [ ] **Step 2: Bind the server to `localhost:3000`** +- [ ] **Step 3: Replace dev/prod SPA branching with direct routes for `/`, `/login`, and `/static/*`** +- [ ] **Step 4: Remove unused Vite-specific wiring once tests stay green** + +### Task 5: Render the login page with templ + +**Files:** +- Modify: `go_backend/internal/web/views/layout.templ` +- Modify: `go_backend/internal/web/views/login.templ` +- Modify: `go_backend/internal/web/handlers/login.go` +- Modify: `go_backend/static/styles.css` + +- [ ] **Step 1: Create a base page layout with head metadata, CSS link, and HTMX CDN script** +- [ ] **Step 2: Create the login page component** +- [ ] **Step 3: Create a reusable status fragment component for success and error states** +- [ ] **Step 4: Match the requested visual structure with local CSS, not utility classes** +- [ ] **Step 5: Keep copy in French and links as placeholders where needed** + +## Chunk 4: Verification + +### Task 6: Generate code, format, test, and build + +**Files:** +- Generated: `go_backend/internal/web/views/*_templ.go` + +- [ ] **Step 1: Generate templ output** + +Run: `templ generate` +Expected: updated generated Go files for the templ views. + +- [ ] **Step 2: Format the Go code** + +Run: `gofmt -w main.go router.go internal/web/handlers/login.go internal/web/views/*_templ.go router_test.go` +Expected: no output, files rewritten in place. + +- [ ] **Step 3: Run the full Go test suite** + +Run: `go test ./...` +Expected: all tests pass. + +- [ ] **Step 4: Run a build verification** + +Run: `go build ./...` +Expected: build succeeds with exit code 0. + +- [ ] **Step 5: Manual smoke check** + +Run: `go run .` +Expected: server listens on `http://localhost:3000`. + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-05-07-go-backend-login.md`. Ready to execute. diff --git a/docs/superpowers/specs/2026-05-07-go-backend-login-design.md b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md new file mode 100644 index 0000000..00b1442 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md @@ -0,0 +1,142 @@ +# Go Backend Login Design + +**Date:** 2026-05-07 + +**Goal** + +Build a new standalone web app in `go_backend` that serves a server-rendered XTablo login screen using Go, `templ`, and HTMX, with no Vite pipeline and no custom application JavaScript. + +**Scope** + +- Replace the current SPA/Vite-oriented serving path in `go_backend`. +- Render the main login screen at `/`. +- Submit the login form with HTMX to `/login`. +- Return a server-rendered HTML fragment into the login card for feedback. +- Serve local CSS and any local static assets directly from Go. + +**Out of Scope** + +- Real authentication integration +- Session management +- OAuth / Google sign-in implementation +- Theme switching behavior +- Multi-page navigation beyond placeholder links + +**Architecture** + +The app will be a small Go HTTP service using `chi` for routing and `templ` for HTML generation. The server will expose a page route for the full login screen, a form submission route for partial updates, and a static file route for CSS and local assets. + +The development server should listen on `localhost:3000`. + +HTMX will be loaded from the user-specified CDN URL: + +`https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js` + +No Vite integration, bundling, or custom frontend JavaScript will remain in the new app. + +**Route Design** + +- `GET /` + - Returns the full login page. +- `POST /login` + - Accepts form fields `email` and `password`. + - Returns a small server-rendered fragment for an inline status/message region. +- `GET /static/*` + - Serves CSS and optional local static assets. + +**Server Structure** + +Proposed structure: + +- `go_backend/main.go` + - Server startup and configuration. +- `go_backend/router.go` + - Route registration. +- `go_backend/internal/web/handlers/` + - HTTP handlers for page rendering and login submission. +- `go_backend/internal/web/views/` + - `templ` page and fragment components. +- `go_backend/static/` + - CSS and optional local assets. + +The code should keep routing, request handling, and view rendering separated so future auth wiring does not force template logic into the router layer. + +**UI Design** + +The login page should visually track the provided reference: + +- full-screen soft gradient background +- centered frosted/glass login card +- top row with back link and theme-toggle placeholder button +- centered XTablo brand area +- French login title and surrounding copy +- email/password fields +- forgot-password link +- primary submit button +- “Ou continuer avec” separator +- Google continuation button +- signup prompt at the bottom + +The background should include decorative floating logo-like ornaments implemented with CSS positioning and animation. If the repository does not already contain usable logo assets, the page should still render cleanly with text or simple shape placeholders rather than blocking implementation on image sourcing. + +**Behavior** + +The app remains primarily server-rendered. + +The login form will use HTMX attributes: + +- `hx-post="/login"` +- `hx-target` for an inline feedback region +- `hx-swap="innerHTML"` + +On submit: + +- missing fields should return an error fragment +- invalid placeholder credentials should return an error fragment +- a known demo credential path may return a success fragment + +This keeps the page interactive without adding custom client-side scripts. + +**Validation Strategy** + +For the initial implementation: + +- both fields required +- simple server-side empty-value validation +- optional demo credential branch for a success state + +This is sufficient for a scaffold and keeps the design aligned with the user request without inventing an auth system. + +**Testing Strategy** + +Use TDD at the HTTP handler level. + +Minimum tests: + +- `GET /` returns `200` and includes core login page content +- `POST /login` with missing fields returns an error fragment +- `POST /login` with demo credentials returns a success fragment + +Additional checks: + +- the Go module builds after adding `templ` +- generated templ code is committed or present in the workspace + +**Risks and Mitigations** + +- `templ` introduces code generation. + - Mitigation: generate templates as part of the implementation flow and verify build output explicitly. +- Replacing the current Vite setup may leave dead code. + - Mitigation: remove or stop referencing Vite-specific handlers and dependencies during the migration. +- Visual parity with the sample may drift without its exact assets. + - Mitigation: match layout, hierarchy, spacing, color direction, and component treatment first; degrade gracefully on branding assets. + +**Acceptance Criteria** + +- Visiting `/` in `go_backend` shows a standalone login page rendered by Go. +- The server runs at `http://localhost:3000`. +- The page uses `templ` output rather than Vite or SPA mounting. +- HTMX is loaded from the specified CDN URL. +- No custom app JavaScript is added. +- Submitting the form updates an inline message region via HTMX. +- The app builds and targeted tests pass. diff --git a/go-backend/.air.toml b/go-backend/.air.toml new file mode 100644 index 0000000..7d3d4e4 --- /dev/null +++ b/go-backend/.air.toml @@ -0,0 +1,27 @@ +#:schema https://json.schemastore.org/any.json + +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ." + entrypoint = ["./tmp/main"] + include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"] + exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"] + exclude_regex = ["_templ\\.go$"] + delay = 200 + stop_on_error = true + send_interrupt = true + kill_delay = "500ms" + +[color] + main = "magenta" + watcher = "cyan" + build = "yellow" + runner = "green" + +[log] + time = true + +[misc] + clean_on_exit = true diff --git a/go-backend/.env.example b/go-backend/.env.example new file mode 100644 index 0000000..619c25f --- /dev/null +++ b/go-backend/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable diff --git a/go-backend/README.md b/go-backend/README.md new file mode 100644 index 0000000..ae0c93d --- /dev/null +++ b/go-backend/README.md @@ -0,0 +1,43 @@ +# go-backend + +## Local Postgres + +Start Postgres with Podman: + +```bash +just db-up +``` + +Reset the local database volume and reinitialize the schema: + +```bash +just db-reset +``` + +The database is exposed at `localhost:5432` and initialized from `internal/db/schema.sql`. +The local workflow uses `podman compose`. +If your global Docker config contains corporate credential helpers, the `just` recipes isolate compose with a local `DOCKER_CONFIG` to avoid unrelated registry auth hooks on public image pulls. +Fresh database volumes are also seeded from `internal/db/seed.sql` with `demo@xtablo.com / xtablo-demo`. + +Connection string: + +```bash +export DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable +``` + +Run the app with hot reload: + +```bash +just dev +``` + +Other useful commands: + +```bash +just generate +just test +just build +just check +just db-logs +just db-down +``` diff --git a/go-backend/compose.yaml b/go-backend/compose.yaml new file mode 100644 index 0000000..95ebffc --- /dev/null +++ b/go-backend/compose.yaml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: xtablo-go-backend-postgres + restart: unless-stopped + environment: + POSTGRES_DB: xtablo + POSTGRES_USER: xtablo + POSTGRES_PASSWORD: xtablo + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./internal/db/schema.sql:/docker-entrypoint-initdb.d/001-auth-schema.sql:ro + - ./internal/db/seed.sql:/docker-entrypoint-initdb.d/002-seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/go-backend/go.mod b/go-backend/go.mod new file mode 100644 index 0000000..20f94e9 --- /dev/null +++ b/go-backend/go.mod @@ -0,0 +1,64 @@ +module xtablo-backend + +go 1.26.0 + +require github.com/go-chi/chi/v5 v5.2.0 + +require ( + github.com/a-h/templ v0.3.1001 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/sqlc-dev/sqlc v1.31.1 +) + +require ( + cel.dev/expr v0.25.1 // indirect + filippo.io/edwards25519 v1.1.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/google/cel-go v0.28.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/ncruces/go-sqlite3 v0.32.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect + github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/sqlc-dev/doubleclick v1.0.0 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.33.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go-backend/go.sum b/go-backend/go.sum new file mode 100644 index 0000000..46529df --- /dev/null +++ b/go-backend/go.sum @@ -0,0 +1,192 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +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/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +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/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20= +github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo= +github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI= +github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y= +github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc= +github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/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.0-20210107192922-496545a6307b/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= diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql new file mode 100644 index 0000000..a338690 --- /dev/null +++ b/go-backend/internal/db/queries.sql @@ -0,0 +1,29 @@ +-- name: CreateAuthUser :one +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + jsonb_build_object('display_name', sqlc.arg(display_name)), + now(), + now() +) +RETURNING id; + +-- name: GetAuthUserByEmail :one +SELECT id, email, encrypted_password, created_at, updated_at +FROM auth.users +WHERE email = $1 +LIMIT 1; + +-- name: GetPublicUserByID :one +SELECT id, email, created_at, updated_at, display_name +FROM public.users +WHERE id = $1 +LIMIT 1; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go new file mode 100644 index 0000000..f288964 --- /dev/null +++ b/go-backend/internal/db/repository.go @@ -0,0 +1,102 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" + + sqlcdb "xtablo-backend/internal/db/sqlc" + "xtablo-backend/internal/web/handlers" +) + +type PostgresAuthRepository struct { + pool *pgxpool.Pool + queries *sqlcdb.Queries +} + +func NewPostgresAuthRepository(ctx context.Context, databaseURL string) (*PostgresAuthRepository, error) { + if databaseURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + + return &PostgresAuthRepository{ + pool: pool, + queries: sqlcdb.New(pool), + }, nil +} + +func (r *PostgresAuthRepository) Close() { + r.pool.Close() +} + +func (r *PostgresAuthRepository) CreateAuthUser(ctx context.Context, input handlers.CreateAuthUserInput) (uuid.UUID, error) { + id := uuid.New() + createdID, err := r.queries.CreateAuthUser(ctx, sqlcdb.CreateAuthUserParams{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + DisplayName: input.DisplayName, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return uuid.Nil, handlers.ErrUserAlreadyExists + } + return uuid.Nil, err + } + + log.Info(). + Str("component", "auth_store"). + Str("action", "create_user"). + Str("email", input.Email). + Msg("auth store mutated") + + return createdID, nil +} + +func (r *PostgresAuthRepository) GetAuthUserByEmail(ctx context.Context, email string) (handlers.AuthUser, error) { + row, err := r.queries.GetAuthUserByEmail(ctx, email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.AuthUser{}, handlers.ErrUserNotFound + } + return handlers.AuthUser{}, err + } + + return handlers.AuthUser{ + ID: row.ID, + Email: row.Email, + EncryptedPassword: row.EncryptedPassword, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} + +func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid.UUID) (handlers.PublicUser, error) { + row, err := r.queries.GetPublicUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.PublicUser{}, handlers.ErrUserNotFound + } + return handlers.PublicUser{}, err + } + + return handlers.PublicUser{ + ID: row.ID, + Email: row.Email, + DisplayName: row.DisplayName, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql new file mode 100644 index 0000000..2be7b1a --- /dev/null +++ b/go-backend/internal/db/schema.sql @@ -0,0 +1,42 @@ +CREATE SCHEMA IF NOT EXISTS auth; + +CREATE TABLE IF NOT EXISTS auth.users ( + id uuid PRIMARY KEY, + email text NOT NULL UNIQUE, + encrypted_password text NOT NULL, + raw_user_meta_data jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.users ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + display_name text NOT NULL +); + +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.users (id, email, created_at, updated_at, display_name) + VALUES ( + NEW.id, + NEW.email, + NEW.created_at, + NEW.updated_at, + COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1)) + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created +AFTER INSERT ON auth.users +FOR EACH ROW +EXECUTE FUNCTION public.handle_new_user(); diff --git a/go-backend/internal/db/seed.sql b/go-backend/internal/db/seed.sql new file mode 100644 index 0000000..0c59bd6 --- /dev/null +++ b/go-backend/internal/db/seed.sql @@ -0,0 +1,16 @@ +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + '11111111-1111-1111-1111-111111111111', + 'demo@xtablo.com', + '$2a$10$/xeyC8tiOZTcw2BBOSrv.uWu.EbRMYwF7MpFcDHSS40fOoTR.QrLS', + jsonb_build_object('display_name', 'demo'), + now(), + now() +) +ON CONFLICT (email) DO NOTHING; diff --git a/go-backend/internal/db/sqlc/db.go b/go-backend/internal/db/sqlc/db.go new file mode 100644 index 0000000..a28f6fc --- /dev/null +++ b/go-backend/internal/db/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go new file mode 100644 index 0000000..ee1ee7d --- /dev/null +++ b/go-backend/internal/db/sqlc/models.go @@ -0,0 +1,27 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type AuthUser struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + RawUserMetaData []byte `db:"raw_user_meta_data"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` +} + +type User struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` + DisplayName string `db:"display_name"` +} diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go new file mode 100644 index 0000000..2a2b436 --- /dev/null +++ b/go-backend/internal/db/sqlc/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + + "github.com/google/uuid" +) + +type Querier interface { + CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) + GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) + GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/go-backend/internal/db/sqlc/queries.sql.go b/go-backend/internal/db/sqlc/queries.sql.go new file mode 100644 index 0000000..c24db1b --- /dev/null +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createAuthUser = `-- name: CreateAuthUser :one +INSERT INTO auth.users ( + id, + email, + encrypted_password, + raw_user_meta_data, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + jsonb_build_object('display_name', $4), + now(), + now() +) +RETURNING id +` + +type CreateAuthUserParams struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + DisplayName interface{} `db:"display_name"` +} + +func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, createAuthUser, + arg.ID, + arg.Email, + arg.EncryptedPassword, + arg.DisplayName, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one +SELECT id, email, encrypted_password, created_at, updated_at +FROM auth.users +WHERE email = $1 +LIMIT 1 +` + +type GetAuthUserByEmailRow struct { + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` +} + +func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByEmail, email) + var i GetAuthUserByEmailRow + err := row.Scan( + &i.ID, + &i.Email, + &i.EncryptedPassword, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPublicUserByID = `-- name: GetPublicUserByID :one +SELECT id, email, created_at, updated_at, display_name +FROM public.users +WHERE id = $1 +LIMIT 1 +` + +func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, getPublicUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.UpdatedAt, + &i.DisplayName, + ) + return i, err +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go new file mode 100644 index 0000000..6dfa6e0 --- /dev/null +++ b/go-backend/internal/web/handlers/auth.go @@ -0,0 +1,396 @@ +package handlers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" + + "xtablo-backend/internal/web/views" +) + +const sessionCookieName = "xtablo_session" + +var ErrUserNotFound = errors.New("user not found") +var ErrUserAlreadyExists = errors.New("user already exists") + +type AuthRepository interface { + CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error) + GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error) + GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error) +} + +type CreateAuthUserInput struct { + Email string + EncryptedPassword string + DisplayName string +} + +type AuthUser struct { + ID uuid.UUID + Email string + EncryptedPassword string + CreatedAt time.Time + UpdatedAt time.Time +} + +type PublicUser struct { + ID uuid.UUID + Email string + DisplayName string + CreatedAt time.Time + UpdatedAt time.Time +} + +type AuthHandler struct { + repo AuthRepository + sessions *sessionStore +} + +type sessionStore struct { + mu sync.RWMutex + sessions map[string]uuid.UUID +} + +type InMemoryAuthRepository struct { + mu sync.RWMutex + authUsers map[string]AuthUser + publicUsers map[uuid.UUID]PublicUser +} + +func NewAuthHandler(repo AuthRepository) *AuthHandler { + return &AuthHandler{ + repo: repo, + sessions: &sessionStore{sessions: map[string]uuid.UUID{}}, + } +} + +func NewInMemoryAuthRepository() *InMemoryAuthRepository { + repo := &InMemoryAuthRepository{ + authUsers: map[string]AuthUser{}, + publicUsers: map[uuid.UUID]PublicUser{}, + } + + demoHash, err := hashPassword("xtablo-demo") + if err != nil { + panic(err) + } + if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "demo@xtablo.com", + EncryptedPassword: demoHash, + DisplayName: "demo", + }); err != nil { + panic(err) + } + + return repo +} + +func (h *AuthHandler) GetHome() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.currentUserID(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + user, err := h.repo.GetPublicUserByID(r.Context(), userID) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render home page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) PostLogout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var email string + if userID, ok := h.currentUserID(r); ok { + if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil { + email = user.Email + } + } + + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + h.sessions.delete(cookie.Value, email) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} + +func (h *AuthHandler) GetLoginPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := h.currentUserID(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render login page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) GetSignupPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := h.currentUserID(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil { + http.Error(w, "failed to render signup page", http.StatusInternalServerError) + } + } +} + +func (h *AuthHandler) PostLogin() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + email := normalizeEmail(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch { + case email == "" || password == "": + w.WriteHeader(http.StatusUnprocessableEntity) + _ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w) + return + } + + authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email) + if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w) + return + } + + h.setSession(w, authUser.ID, authUser.Email) + w.Header().Set("HX-Redirect", "/") + _ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w) + } +} + +func (h *AuthHandler) PostSignup() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form payload", http.StatusBadRequest) + return + } + + email := normalizeEmail(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch { + case email == "" || password == "": + w.WriteHeader(http.StatusUnprocessableEntity) + _ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w) + return + } + + if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil { + w.WriteHeader(http.StatusConflict) + _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) + return + } else if !errors.Is(err, ErrUserNotFound) { + http.Error(w, "failed to check existing user", http.StatusInternalServerError) + return + } + + passwordHash, err := hashPassword(password) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + + displayName := displayNameFromEmail(email) + userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{ + Email: email, + EncryptedPassword: passwordHash, + DisplayName: displayName, + }) + if err != nil { + if errors.Is(err, ErrUserAlreadyExists) { + w.WriteHeader(http.StatusConflict) + _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) + return + } + http.Error(w, "failed to create user", http.StatusInternalServerError) + return + } + + h.setSession(w, userID, email) + w.Header().Set("HX-Redirect", "/") + _ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w) + } +} + +func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return uuid.Nil, false + } + return h.sessions.get(cookie.Value) +} + +func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) { + sessionID := randomToken(32) + h.sessions.set(sessionID, userID, email) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + userID, ok := s.sessions[sessionID] + return userID, ok +} + +func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = userID + logStoreMutation("create_session", email, sessionID, 0, len(s.sessions)) +} + +func (s *sessionStore) delete(sessionID string, email string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) + logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions)) +} + +func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.authUsers[input.Email]; exists { + return uuid.Nil, ErrUserAlreadyExists + } + + id := uuid.New() + now := time.Now().UTC() + authUser := AuthUser{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + CreatedAt: now, + UpdatedAt: now, + } + publicUser := PublicUser{ + ID: id, + Email: input.Email, + DisplayName: input.DisplayName, + CreatedAt: now, + UpdatedAt: now, + } + + r.authUsers[input.Email] = authUser + r.publicUsers[id] = publicUser + logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) + return id, nil +} + +func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.authUsers[email] + if !ok { + return AuthUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.publicUsers[id] + if !ok { + return PublicUser{}, ErrUserNotFound + } + return user, nil +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func displayNameFromEmail(email string) string { + email = normalizeEmail(email) + if email == "" { + return "" + } + return strings.Split(email, "@")[0] +} + +func hashPassword(password string) (string, error) { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(passwordHash), nil +} + +func randomToken(size int) string { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return hex.EncodeToString(buf) +} + +func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) { + event := log.Info(). + Str("component", "auth_store"). + Str("action", action). + Int("users_count", usersCount). + Int("sessions_count", sessionsCount) + + if email != "" { + event = event.Str("email", email) + } + if sessionID != "" { + event = event.Str("session_id", sessionID) + } + + event.Msg("auth store mutated") +} diff --git a/go-backend/internal/web/handlers/auth_test.go b/go-backend/internal/web/handlers/auth_test.go new file mode 100644 index 0000000..ad27bbd --- /dev/null +++ b/go-backend/internal/web/handlers/auth_test.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" +) + +func TestSignupLogsAuthStoreMutations(t *testing.T) { + var buf bytes.Buffer + restore := log.Logger + log.Logger = zerolog.New(&buf) + defer func() { + log.Logger = restore + }() + + handler := newTestAuthHandler(t) + + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.PostSignup().ServeHTTP(rec, req) + + output := buf.String() + for _, want := range []string{ + `"action":"create_user"`, + `"email":"new@xtablo.com"`, + `"action":"create_session"`, + `"session_id":"`, + } { + if !strings.Contains(output, want) { + t.Fatalf("expected log output to contain %q, got %q", want, output) + } + } +} + +func TestSignupHashesPasswordBeforeStoringUser(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.PostSignup().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + storedUser, err := repo.GetAuthUserByEmail(req.Context(), "new@xtablo.com") + if err != nil { + t.Fatalf("expected stored user, got error %v", err) + } + if storedUser.EncryptedPassword == "xtablo-secret" { + t.Fatalf("expected stored password hash, got plaintext") + } + if bcrypt.CompareHashAndPassword([]byte(storedUser.EncryptedPassword), []byte("xtablo-secret")) != nil { + t.Fatalf("expected stored password to match bcrypt hash") + } +} + +func TestLogoutLogsSessionDeletion(t *testing.T) { + var buf bytes.Buffer + restore := log.Logger + log.Logger = zerolog.New(&buf) + defer func() { + log.Logger = restore + }() + + handler := newTestAuthHandler(t) + + loginForm := url.Values{} + loginForm.Set("email", "demo@xtablo.com") + loginForm.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginForm.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + handler.PostLogin().ServeHTTP(loginRec, loginReq) + + sessionCookie := loginRec.Result().Cookies()[0] + + logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil) + logoutReq.AddCookie(sessionCookie) + logoutRec := httptest.NewRecorder() + handler.PostLogout().ServeHTTP(logoutRec, logoutReq) + + output := buf.String() + for _, want := range []string{ + `"action":"delete_session"`, + `"email":"demo@xtablo.com"`, + `"session_id":"`, + } { + if !strings.Contains(output, want) { + t.Fatalf("expected log output to contain %q, got %q", want, output) + } + } +} + +func newTestAuthHandler(t *testing.T) *AuthHandler { + t.Helper() + return NewAuthHandler(NewInMemoryAuthRepository()) +} diff --git a/go-backend/internal/web/views/login.templ b/go-backend/internal/web/views/login.templ new file mode 100644 index 0000000..a879cd0 --- /dev/null +++ b/go-backend/internal/web/views/login.templ @@ -0,0 +1,219 @@ +package views + +templ AuthPage(content templ.Component) { + + + + + + + + + + + + + XTablo + + + + +
+
+
+ +
+
+ + +} + +templ LoginScreen() { +
+

Se connecter à Xtablo

+
+ +
+ + @AuthDivider() + @GoogleButton() + +
+} + +templ SignupScreen() { +
+

S'inscrire à Xtablo

+
+ +
+ + @AuthDivider() + @GoogleButton() + +
+} + +templ HomePage(displayName string, email string) { + + + + + + XTablo + + + +
+
+ +

Bienvenue

+

{ displayName }

+

Session active pour { email }

+
+ +
+
+
+ + +} + +templ AuthStatus(kind string, message string) { + if kind == "success" { +
{ message }
+ } else if kind == "error" { + + } +} + +templ AuthDivider() { +
+
+ Ou continuer avec +
+
+} + +templ GoogleButton() { + +} + +templ AnimatedBackground() { + +} diff --git a/go-backend/internal/web/views/login_templ.go b/go-backend/internal/web/views/login_templ.go new file mode 100644 index 0000000..f508771 --- /dev/null +++ b/go-backend/internal/web/views/login_templ.go @@ -0,0 +1,342 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func AuthPage(content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "XTablo
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AnimatedBackground().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LoginScreen() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Se connecter à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Pas encore de compte ? S'inscrire

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SignupScreen() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

S'inscrire à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Vous avez déjà un compte ? Se connecter

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HomePage(displayName string, email string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "XTablo
\"Xtablo\"

Bienvenue

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(displayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 135, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Session active pour ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 136, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AuthStatus(kind string, message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if kind == "success" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 148, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if kind == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 150, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func AuthDivider() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Ou continuer avec
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GoogleButton() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AnimatedBackground() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/justfile b/go-backend/justfile new file mode 100644 index 0000000..97bc6ea --- /dev/null +++ b/go-backend/justfile @@ -0,0 +1,61 @@ +set shell := ["bash", "-cu"] + +database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable" +compose_config_dir := ".podman-compose" + +default: + @just --list + +compose-config: + mkdir -p {{compose_config_dir}} + printf '%s\n' '{"auths":{}}' > {{compose_config_dir}}/config.json + +machine-up: + @if command -v podman >/dev/null 2>&1; then \ + if ! podman machine inspect podman-machine-default 2>/dev/null | grep -q '"State": "running"'; then \ + podman machine start podman-machine-default; \ + fi; \ + else \ + echo "podman is required" >&2; \ + exit 1; \ + fi + +db-up: machine-up compose-config + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres + +db-down: + @if command -v podman >/dev/null 2>&1; then \ + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down; \ + else \ + echo "podman is required" >&2; \ + exit 1; \ + fi + +db-reset: compose-config + just machine-up + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down -v + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres + +db-logs: machine-up compose-config + DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres + +generate: + go run github.com/a-h/templ/cmd/templ@latest generate + go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate + +fmt: + gofmt -w . + +test: + go test ./... + +build: + go build ./... + +check: generate test build + +dev: db-up + DATABASE_URL='{{database_url}}' air -c .air.toml + +run: db-up + DATABASE_URL='{{database_url}}' go run . diff --git a/go_backend_deprecated/main.go b/go-backend/main.go similarity index 100% rename from go_backend_deprecated/main.go rename to go-backend/main.go diff --git a/go-backend/router.go b/go-backend/router.go new file mode 100644 index 0000000..fc9cd2a --- /dev/null +++ b/go-backend/router.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "io/fs" + "net/http" + "os" + + "xtablo-backend/internal/db" + "xtablo-backend/internal/web/handlers" + + chi "github.com/go-chi/chi/v5" +) + +func newRouter() http.Handler { + databaseURL := os.Getenv("DATABASE_URL") + repo, err := db.NewPostgresAuthRepository(context.Background(), databaseURL) + if err != nil { + panic(err) + } + return newRouterWithHandler(handlers.NewAuthHandler(repo)) +} + +func newTestRouter() http.Handler { + return newRouterWithHandler(handlers.NewAuthHandler(handlers.NewInMemoryAuthRepository())) +} + +func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { + mux := chi.NewRouter() + staticFS := os.DirFS("static") + + // Views + mux.Get("/", authHandler.GetHome()) + mux.Get("/login", authHandler.GetLoginPage()) + mux.Get("/signup", authHandler.GetSignupPage()) + mux.Post("/login", authHandler.PostLogin()) + mux.Post("/signup", authHandler.PostSignup()) + mux.Post("/logout", authHandler.PostLogout()) + + mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static")))) + mux.Handle("/pwa-icons/*", http.StripPrefix("/pwa-icons/", http.FileServerFS(os.DirFS("static/pwa-icons")))) + mux.HandleFunc("/logo_dark.png", serveStaticFile(staticFS, "logo_dark.png", "image/png")) + mux.HandleFunc("/logo_white.png", serveStaticFile(staticFS, "logo_white.png", "image/png")) + mux.HandleFunc("/manifest.webmanifest", serveStaticFile(staticFS, "manifest.webmanifest", "application/manifest+json")) + + return mux +} + +func serveStaticFile(fileSystem fs.FS, path string, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + http.ServeFileFS(w, r, fileSystem, path) + } +} diff --git a/go-backend/router_test.go b/go-backend/router_test.go new file mode 100644 index 0000000..fc1b908 --- /dev/null +++ b/go-backend/router_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected status 303, got %d", rec.Code) + } + if location := rec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected redirect to /login, got %q", location) + } +} + +func TestLoginPageRenders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + "Se connecter à Xtablo", + `hx-post="/login"`, + "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", + `href="/pwa-icons/favicon-32x32.png"`, + `href="/pwa-icons/favicon-16x16.png"`, + `href="/pwa-icons/apple-touch-icon-180x180.png"`, + `href="/manifest.webmanifest"`, + `src="/logo_dark.png"`, + `src="/logo_white.png"`, + `data-testid="auth-card-shell"`, + "Découvrez la nouvelle expérience de connexion", + "Mot de passe oublié ?", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} + +func TestSignupPageRenders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/signup", nil) + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{ + "S'inscrire à Xtablo", + `hx-post="/signup"`, + "Vous avez déjà un compte ?", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q", want) + } + } +} + +func TestBrandingAssetsAreServed(t *testing.T) { + testCases := []string{ + "/logo_dark.png", + "/logo_white.png", + "/pwa-icons/favicon-32x32.png", + "/pwa-icons/favicon-16x16.png", + "/pwa-icons/apple-touch-icon-180x180.png", + "/manifest.webmanifest", + } + + router := newTestRouter() + + for _, path := range testCases { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected %s to return 200, got %d", path, rec.Code) + } + } +} + +func TestLoginReturnsValidationError(t *testing.T) { + form := url.Values{} + form.Set("email", "") + form.Set("password", "") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } + + if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { + t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) + } +} + +func TestLoginCreatesSessionAndRedirects(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { + t.Fatalf("expected HX-Redirect to /, got %q", redirect) + } + sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + homeReq := httptest.NewRequest(http.MethodGet, "/", nil) + homeReq.AddCookie(sessionCookie) + homeRec := httptest.NewRecorder() + router.ServeHTTP(homeRec, homeReq) + + if homeRec.Code != http.StatusOK { + t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code) + } + if !strings.Contains(homeRec.Body.String(), "Bienvenue") { + t.Fatalf("expected authenticated home page, got %q", homeRec.Body.String()) + } + if !strings.Contains(homeRec.Body.String(), `action="/logout"`) { + t.Fatalf("expected authenticated home page to include logout form, got %q", homeRec.Body.String()) + } +} + +func TestSignupCreatesUserSessionAndRedirects(t *testing.T) { + form := url.Values{} + form.Set("email", "new@xtablo.com") + form.Set("password", "xtablo-secret") + + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { + t.Fatalf("expected HX-Redirect to /, got %q", redirect) + } + sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, loginReq) + if loginRec.Header().Get("HX-Redirect") != "/" { + t.Fatalf("expected signed up user to be able to log in") + } +} + +func TestLogoutClearsSessionAndRedirectsToLogin(t *testing.T) { + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + + router := newTestRouter() + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatalf("expected session cookie to be set") + } + + logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil) + logoutReq.AddCookie(sessionCookie) + logoutRec := httptest.NewRecorder() + router.ServeHTTP(logoutRec, logoutReq) + + if logoutRec.Code != http.StatusSeeOther { + t.Fatalf("expected logout status 303, got %d", logoutRec.Code) + } + if location := logoutRec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected logout redirect to /login, got %q", location) + } + + clearedCookie := findCookie(logoutRec.Result().Cookies(), "xtablo_session") + if clearedCookie == nil { + t.Fatalf("expected cleared session cookie") + } + if clearedCookie.MaxAge >= 0 && clearedCookie.Value != "" { + t.Fatalf("expected cleared session cookie to be expired, got %+v", clearedCookie) + } + + homeReq := httptest.NewRequest(http.MethodGet, "/", nil) + homeReq.AddCookie(sessionCookie) + homeRec := httptest.NewRecorder() + router.ServeHTTP(homeRec, homeReq) + + if homeRec.Code != http.StatusSeeOther { + t.Fatalf("expected logged-out root access to redirect, got %d", homeRec.Code) + } + if location := homeRec.Header().Get("Location"); location != "/login" { + t.Fatalf("expected logged-out root redirect to /login, got %q", location) + } +} + +func findCookie(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} diff --git a/go-backend/sqlc.yaml b/go-backend/sqlc.yaml new file mode 100644 index 0000000..e9cc5a3 --- /dev/null +++ b/go-backend/sqlc.yaml @@ -0,0 +1,28 @@ +version: "2" + +sql: + - engine: "postgresql" + schema: "internal/db/schema.sql" + queries: "internal/db/queries.sql" + gen: + go: + package: "sqlc" + out: "internal/db/sqlc" + sql_package: "pgx/v5" + emit_interface: true + emit_db_tags: true + overrides: + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - db_type: "pg_catalog.uuid" + nullable: true + go_type: + import: "github.com/google/uuid" + type: "UUID" + pointer: true + - db_type: "pg_catalog.timestamptz" + go_type: + import: "time" + type: "Time" diff --git a/go-backend/static/logo_dark.png b/go-backend/static/logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4cee136e1cd0793fcc136c219662836c87b8abb3 GIT binary patch literal 85535 zcmeFYWm8;T*ENc}yIUZ*yF=qnkl=2?y>STc?jAh2yF0<%-Q8W^zUtKd<@|={Ll@PX zUG%2cTyxAJD_mJo3K@X_0SpWbSw>o11q=-Q{=XL-4Cu<(;#LIc2e`9}6acJhlIR2s zj0{XhTvW|n|J(=GP;Kz>bDXg~GiVy1fh8_puW#fGHmWM5RPG0U}LbNG6Tk8;w8&VJ+# z`^^y^3JL-O-2bXsY|KIoUfA#SH-xpYj{-LD4JlpkCKyE?z>!+%a;*H;|P53aiCmBBjl?(rJ zNod$U%D3yan&R!-e4JGY^O^O9iEXsIDOoL)C6{Y5V{#)xy@UA=42YlXN?45GdI3X< zbOpAw-SEllD(Kqe>hT9^jH1lcf7DS*v{jTfMQJw%d|57(Ep#0pKZ_+&qsrVt!ii&# z!h`Db0LsP87r6+l`{QvfCX`EP!FFrv?Ym0Q>*~d$cAK4YP>nxYAD#*HCn`A|Iv`$= zqpYm#yUa*>a&orQ#{;t4cn0S|EBZId_r(qmXA83-u_Qe*Wh|)QBTpA^7sYz*K!p-4 z)T#0DGK+dzlzuWz09!mI{7@{^RDM#OlvM_zk$gBMEt6J!;x5di-(43hm*7l=)(0Y< ziEm;H6a+LG8JwO=Gz2;r1u2h%;RL4Cl&cJ1u7DsTC#SBuEqNV-i5T~?ZczM6@VNcJ zpD^VBO8i6Gv}zg^Ea;Oqx#l%n3+;wqpLX@;+}&G6afM;hiVB4wyu9v zr~Tz7aW~um7d`czvn}(KaFBmW8-k4`37;BiVH2N=vcr=@;X*LvsbTFKMk&6$f!G^p zGely|;r6q@gJB3OHBOv6TPVM?9!qKVVY6Kw_d_Pa^Z9wcT<+=C-u4+VEs@)T;4hQ- zomc47ed%{p)R>!Ug!YdJ+pt@Opf~d~sb%#VAL#Sp6~=4QqMpNd?j(XkD2Z)1wG`H( zB;7Lo&R300F2U#h=bLYzp{(p1k#c0pmgvV@e#g-UzAp!o0A&|oQ+bm@qnvm2SW1(5 z=d-4fNl|!AEyq>RSlMNhk?A#=7KCv`*B-Pr5dZ3yU78FfG32C ze%2s^j|w?2CL_T`g2p1!O8GDCw7sp>Dm;!8nqs!;lKsYA#)x;#Q zkt%2zZ7_lIABJX=sn$Py?s4zQp`gsw$g_GB{hMuQ=CL|O^jR$E#1uw*VHV?oYh<~U z@h`PF&tM>Iu6rw)OYX0b2*!aZe0GQ}pQrA-xjANL73{_sBRmYZZSrOFdh%2`lNbRF z1L$+gah=NUL%c(UP_pF6npY+edAPMw~M31E-gbQgsC?*x;BIj<>HFz;WJBU^{PgRafB8W{<6d$LgpFi zZ2?zuci;%lxxVpAs(KW_s7cL623pQ zDl!bg%j0~xwp6{tL;S%9%^#oL{BQTCC(UioC*%}i+YFu&K0Zv4MOheH0w#1HZZFmk zIE67s-}(x56?C`_K|%IlSjnxV;PzfOtrVJ|-W6hmh%mS`idfNyVe*OhiYq`Z+kbErIuqFM~1LGUuI_ zR#)St6Ob@TGt;(-9eODOR08{&R~xN~FR?K{j`Jnzx2(3hI+aIn2Pah}#**UV$Vho_ z-R@@!hLdC)K6p2xXhM(H^Ci!u_qgipvqSR|*5dhTz(J1O9{6L!rV*akIjFc455(VU6hIndC zBUxg`iWD|%w>7w)@XX7#V{AO{Jf#y%230@4JwMkNiEa7phKF*QMAdiPYu{-4i`1eW zFIGzJe|~h^i%Z)D(lnj5;Tz`IKWo*J_*`TfF&r{fY^ZVv1&<=U$D1};FTK8ju*_Do z#bFV*@9kqX?sbhgVEUs{B2t-@rT%M+`in5rlhrnzl9ro-vUFS`+J1PV{2iV+DuSzB zw!V&yGbn7%u31mN;n$9dSV+{yYN=}dJ%yUWItAJ_0t8ydwK6h|OF%WMU{6&7CZRc6?Qz#O>S(`MCEfA20(?!so zwK1{J;LWYn(@fFb2nM6IStFTa$93P)-HCa)OUr`NpCip0$ zc~V)|%wlnqljjXhW*baRf`CdjVP7(#a3n)+7|lE}b9{&KK<;?E5plDpu0k0=mX_)T zE`vcq?rN1w$n$4wY|6&x+}FsLSpgL-mk}PKd26$4=|uJDa?Z6t5K}wAK^~qm4xS4M zGLW5U&im$29WKxL$}OC&8w(!YJRsGqz-%m~8M`)DO#+NXrE zLmR{4_gdpwe>&MwM4Z~%e9BNw@l#Z)D{j6we7468#U%|a!A%G_lP6v&+N>m!Rr46W z8$-s|^J*hKmvL0JJ2u0Oga7YvkrKr`Jrok2dl-NdX8#}gMDW+L;?|l9=@Gl;yozxf zxc+-4b7(ZaNCI*g73^d)D@Z8;3I94n1R>f=o#t2P$#r};`81YYXCvPyJIt59Iq1AM z0KI!Fk^qP8)Id_wycbIXp?13?{lIs>owQpj7m|K4be_&TXc#0Ngk<--g zO4%K?QnAOC@bI;z#sRlZ!=-((u=bCM)Kf0J4%<^p;r^hS4}n8leyL-T`zQY=pOA@C zuA|%YfzC7(1)ahnW-6hpCP;eQXsUtaPZkFq-}{v1-1lgFftD}PC|089$T1~!5VI>m zwpK^%SP_1+nflw%5LJXXra%jBV$?;)N+RtZRCM6J=!s90IE@ZHEI@y`ahkKefkXb^k08VLFH?d%uE|G> zm^kT-;QO3^MH7fx2Ew9xSZ(*DRR+Q!zYfD9FhwZKpo8%)Rd;VL9qj*0)Zv(DNe=xn zie{4I%r^1CH(p^m9zLh$W5VFY)tv`nn_aO*gUp;$!wvg zr%tW(Dkj4Lwheg}_qhIRqr=DYgxp&p#ILJ(>PByW8dEA!q?GYsMu8qt#W_RPC^Yw@ zX1k5k;~Jwt4MsiBVLz6k$y>$-_sr)11Q143`t4TLVtM$_bRcFH<519H&sX4da0p%=Tm{J5PQ z&X2QRXN%Ytr`XdEbPx3WF2|d9Q^H@%W!C#RQD*r0^gVJ!I9{WL#iM7yoA9Abo^rpDw>H$SAKyHl z$WJ+6dEv`ov*j0$7d68tL`UE7%V0CR%?8!utG=lvJ^pytJ+0_**E|6ggB)mkHVSUN z^9XSAy5bMbPQb|^*wE3Lm+1A$V!fFNVADf~Gs(LE?$1Mz3BNyF5`WBwp$8g-MMn!7 zoJ&QZs^LRXeEXJ+h^yk2!{-XDoGW#Wuc0{Oi{DvacV^Zv1^-9xLXS1CM;HZ9rAEz_ znXynHbsXR2e6W!_n1*kTURlPXM_Xoq>vt>($TCdtPPeo#&+jh5&_{`Hy{y~v(P2hE@mPb>*>%N zpauBeq)S+8WAGiW)8>X@sYLYGDo8&NdYLV{FDkRL7Na<1k4sO${yr`0dJj zhid-z3}Q})gAkm!fnbaF<28DJlcdDMDv$BNZ%XD4eSx|Jay0z(zm(KI^Lz8@b5o{f zt=fhR{xp!z1cTe}l4LUxyL7*&MZYiZIQ$M`48F-HB!H$?ZJ!7Mt$R!q9{QDH>M&OMgwzD}f;C>Nan&c6HO>RIk2HFd16l9f_(CvP=p=NC53j&_VD+4PLL zdT^^27ba+t$g(M^GJY^F$Fmvjws3RVcYO&()jA~6Pd(n`xiwm^wBE(<4L6}dHgmz5 zD;6Xm4P2x+1%K{u1^IH8)^FA9OLf#D_5VQ$m9@8=c|QU{er1lJ*Jw%K+eRqgEoTG; zT<2{A_UTe}?#D3c7oO+IY>_&~Pdip5giya6e-3qHi<~)A-f&h1X8oonR_gC)Gh_}z zX|~RP)8~p6Jx%71ch1^fP7){pt8_m~n9Y6|S#+5UA7z7@gKK8cfq7dDmb$BoWFnvd5-AXQ znaexlFg%+EB@*TOFH2Liu7+boRy-37w%yc}lr~vHKD#J+G)Mi}8w?rUkGfk@U3gSM z?02!ko4lvD2lR+oE5L6>BcgB&#iG^(lku$(YE{HiDAgWZj*bI55kGnmx;fpr4>2)E zmI%>O#6)HZJ{>n)k=Z;YB)VVL5taO3^nP_~xVL*$gKewM7Au5agXX%;Bd3!K96^2uS88T8_eveDy3olH^^POJT)-oUr7c8|M5!1qP3 zjiz}eoFIKGxX!oVAhrqBbvOAnF+M)7Uoq13%9IKtjzUsq-ob#Y>6qw|Bahw+But^5 zQB7WLKX}@lL)6a#%gcMhE)z?5Wwex2fKXK*{V(oKb|elXg8SVB>Tq#!VFe44XF=I&%^39S z0me^t=vH0DV%y8f(-Bp^!!{@MHrJ5D*q}>A0Z7cov4h=k+O#P$V8 zV*b~ELbdUkyL)?6k?ao?z!6yoLQ{pc_9sISygu)FOqg-ht`JGjr8Dt=bKP*LjbkjC zv&3jS2uOJHUQu#8TZ*#@Rn}+%3z30Vq!}M%9p?riS*$`Di_s+rf`AbWwq3kKwbX@<{8@XxBqhYR{ADo{l`53xZ=oVf7vRv>R(&aHfMBj88g}&ZP%JWyHsje)G-2j^&G_F5QTx~ zSFRb6L5>UD4g?dte#VOo^p^rhE4-2Vegve!WEwpc_IU%+*^Ix*wZ%oznkO0cgF=E` zKid6tzh2~UCccnN$T4-YKD9`m5CF`mNs%ux&(F`>pjPAxkd+-(QE%jikRpLgo-oMV zAO)@sLj4VD&P+i<`0)lBD_u|L`PhF=_r&IU-^Pna1y(C;|O=}P~J2mB#UB8}-Fehq?;~JRnAA<`QGu4_=OhCgQkx*8* z$*dzx$r!I1u~1O4H$|~B#}0#l^6T?S;iyyi^O~=(1s%W!%S?DXnh%T7y!vmn^L1&0z>dh(a7iD3DUYlDU;R5_(7 zO?g@?CPoSOn_C{K!+8dpJ<*dMp|Xm(HGoJuYP{Zf^VbA^z2B1`qgD+ z=PqrOnO6PpS-KqRrC{c@jc67~n-grNdIxsGMG|fA zLVjnJ7vB`We9Qyw@Ur~}R$5yJW={d`1PSR=GB3%)!dn9ixUKupiIG%odEeBNqZcc6 za`1$_k6)?eF5PwFJO`V*48tSrA?Hh`p8s@uxlN9YI#sbCsSrY$&^Z65m#!VXJ$|%6 zkE*lBlXgW$`C$`Kf-&bGs!Xgr+!sdCt}vM+WU{J*YNy|fXOgAzNQI`vKo3R6QE9zI zLhQHZcgz`3XwH=Sy|gTv(HmK8IAXMbz>t-pHqF^FnFw6*xU4My{G8!X7h!M6T%#8# zz1C#w<8-;MQ`*ew*<=5JPr^9(S?L1LUThOZcLftqERTITX2~xvJD{vs7|v4%--ui-iEify8ZTr zjRWZT+74W)-RpCC9NW(PKm2S8)LQYAAgE-FBEmy@*Dywi>TZloUviQ(PTp?>*JGfU z`@BsD%f;Xr#aGGXEnQp&ZnWx)W1=I^o)Y0K%UguV;5v6}Eb=Vdfgdb%-@ciI2PJ31 zgQHuX4Im(-#8%c!^V}g+$eX!y_D{~9hE!dpL><7>xkSY0EtDG^$>%PoaZ5%MJi5!v zM=2-WGXVB|mdhVtY%5}|< zsr{tX(9}%z2@Dys%@MXZ{DQypIzeMTDUncOrgbWMCyINq$n_BfO;{$uWvhfHg@4Tt zIqv>>%;jd716g^KiC(f?5>mp#O48uu@?=~#%xKDz^r#CLi@+h@I&ZfF3AvX3{t!#s zl8f<7_pMXNjM0lFeIq2==EDfE4U?rK%Fsz+q+da3#Icv>`62IYHV20jV<(`{`(5^L z)i-gyi?wDx9;3eHXy>Cz2o<;F39mubh=||EClV56NLP_jS^GRLzn-4=_ao(qV|qN9 zO3(|>+Kij|(|98dJm5a)*lro(u3u?A?=MN{TOQ9jcpblU2B*__0rvOJ-_;mO z_kfl6{dfuV#eWK?i;jObstB;~6-(&H!lKSSj3?i$>mAf5*2|B2P(FDsrOQ`<1Hi(_wBnV^e=;gX;j!90jKtqT5?7ezjj>AZ;R3g4~xXqNIuzx2h5;J0blGURlQ+i=Ke#$W0^>ThrA@&*Lv_Ei&bF417i5)IbJhK+^xwo$$giDOLj;9h*9Sv?xb{j(PBk`D1WC7Y_ zNS|kHI`6mMFj+kAYX_@|nO@GHkGE9!hnFV^SN z&Sg7Ys9cma*TE9W4COW?dvc5s=fL2GUo-Ff^QDQYlOT_E3y+;y!qEdAWZCLNEqS__ znilRgZ^cB*D|Ne|0$RcuRy6aI%MC7C6KQNT;-3nPVO1K;8AkE*9w>d8ZJ=7=^_SE$ zvsf)}2P{I6BfZ$zRDv3?k?#^Vpf}I`K3t2(?7NA*k$;rdQkLp+e77$%oEr9$bjawB zY|eig4qd2!CdmFm97Px~N0XLAFiIfthT((b9k%us?Yj&OmQ}{s3+jCM7kTBouN;!a<+%#b0nK**|vB zZy0w=diP|)U1wv`7^BM~6TjD-E|fb9G1+a0Dha$y3*zl@*mrf&3AMYPO`;FS5F1F* zUi`Ie3#hFasiW}cYG;-uyE7y07C_$rsfBD+0}0**3EOsb+usxHfx)B|`JOe=Vm54& zT#Z`SFx!PyIEFOJ1h$-}isHy> z)32ETw@d9VPe%t9oR|5rKW432)-IU_elIVO4F8Z_QgkH($ede#Pp9|gR_jGgiwf{` zi*Y!MO0$AX2}vF!2)l*QAUtO^UYI9y@MfZW#U$p++~GN)Kd-8T<@N7M%;`%1pww)_ zONavLf^Nstmsdq@n;uf@+rD4e3Bom(&;KbyxAyq6oGC54rX;aLqX6$&o_dPGvjV#S{X>M9DzKN9jlLaB; z7}phw)YvNW0|tybJ;`xROuV`~JSXbj2)OMSunuH@bL_Kkb$tYmsQTruY`dIhQ^}a^ z4|Ob?f#7|bVJ<7J{s5}B~5oQrBA{X*?i$hGWs z%P+SI6y|_Fy~`TMNB$XbJ+I5};>>8w3w1c|CXfK@4F?9f+}Lcp_PRWsCw56HEVMHL zgG``%)5%qtMFp~=LM|4o4Z2Ugo<6y#882oj;OSjGAkd7BvvS2ErHC5TQF7P)lgOo$ zFMbr0v?NalznMDPo2I8@x`K9-6)v?xPR+&V+mc-$o2X6VkZ9M1teKrb(9Z2Kh#K*1anL*j(5y$>yyeZ`EiqQ)9SEo27_L-0ojxZ0%1#5-X@N7aJK-QGdCu z;GNsTaI z_L*GCe1ew)-~kJ2w&z@sd5N~^#ZD%;x!Fm4?sx3-^ZORR1$B+c878T?K3!fJgO;QD zG1I>5%CM(czo|BxuO8)J&97^T$-trOLiDHSk!n~6M5_6S6~nr|Z+)LR{1%t9&(F(B z5-CmAvFX+uv-B6%-EHCY}Qh>z8Y`R*g!R zSFK}64`4?;Jj!|V)08r^ZBNIqOUFM7NW){V-mcnQ&&<_Av|p|8=_PP))UW^Of(+a0 zX0NBobd4%g#R9c5dbtEyxP)GlCHAg)K~e0&=y4RdiL%%AsDm*DWks`g&%ZqbP`D83 zWU^#j;T$%a%$T;RJIdD{-#7dSq|flGX-Lx)K}~r(lGWsiIy7hIo#~b6)JOmj8O81Ei|VTK%yg zPzA0{_ZKUb?1kJeb5r6JCY>tpMCZi8MX=@R<*^fcJYfjZ07Mi&u!7e;t?w`~zeo9* z?vM0VhqFKIJ_t{>gZdB=KZoUPVFsyhi?fIMVe-j9&w`OEN}x`P84@{RnH6LxPvT#N z1SXc52#Mn^>Z~6KVRSE2f}v8E5;*efWeVWCdD{)p@rYzJ7LrYtZp__p!cXhfB>4Dq7bT>A9-M2XoWbnI5(jNB0ydF4Rm=D`RX%O_7ZZ#9UCrCULSQ@5 z9ZZT8au{|Cbw!T@n5cg9T_$JkL@2{E`4FwvOH9M33xq!}Hrnm|^AfFqUSLkGxvZ9E+^A>LJ;|H8No5frCq5nn=dJrPsex% zZ7}8z2XZs;e4e0O5n%+zVdGX$Lma**T` zQcEt51RyU&&Puf@L)zh;jQ@s7LW;KVw)4z-b0mSG6a-QaZAty!J?8yCU~|5~wCVr2 zke=%a%$7N3Cw2(!pL8(uKz90@8T*X{{Lm|CQlO<(h6q3i;`Xyl+5D%dHXVLSN0YWG z5oyX)TQXiUT{0$r zo>OMc`+9tyx=BPTpE$98*%I3`?%>2#+)rs5 zuM7Ou3*W;0T)Db6B3>u&^6Yk?aHb<|J-ty}&zm<3O6Q*{ZLs?5QdCVM7E>H1D3j3P zBQerH4LZ$}C85ZKb;k35jjlj?-tGi6XnYUFC15iJj67}mabD&6B^>b*fj1&$yYt-a z**=az^C!ZOrq963NG12PvfEq3~IKj>#%hKF566I&r1fVZ-2;Z?y;O+Sc*dT z>l_JBIt&f@ADcw;29SQG?%;&Ez2S<5bY=RAipu%eGdw&TImhGh;E1Wn-~^St2CZo* zc8~7GEIX407G4Q(l!A-4lLyzaRg1pZxps5lww1ly?l#~BEditE#4+0O3|cifOOWe# z0Wy&JIeA}pZ2-UYLcdMtWEauuTCK8GA~HWv^qnj#iNwVHD3E-J^8a}2 zX8Sb~Ck2aq>bzJD&O#5HXSwC`;QV}(ID9n+Z(DH2mtrO{q?v%6%@PeWW^+OB?f^Ja zn^e+Zfb6hj;A&h+hn7s~9ghisAg1Bt)77bS`Il;Bl)Xe2)Wsa0zJVDyOzPM1>hmOc zE3R!dqzPboUVeE&$u>dGX9u>+a%_~2)yaF_!@YT}x5S@;f(jqXXl;i_>{r?z`gV|`uvg5sG&gMxH# zvp09WsL+Faqq#sHf_&wa(3_Q(x%nbdKUweg`S&q|$Ws4A70?{bQJI-Bcit-GCVuQC zI6Wl`wc(G(G6*=6o7T`&!8K<)aH^d=nJc#OJB~+od$vARC%aS3_H_CB*v`YH?c?@8 z#phs0yeNQKOy_P#=FMiJG};==8N&S2i;|sNlDT>+?~9XH*X^GRqEE&ZprL2( zkywWr9tA~6jex!VNO`%-r`>iU<1K}R|M7_czPyZo#ej;GWr@s1UHu0T_%r6Q-AZ-) zIHJjN?)IG6ZM@rUe?M`Bz>i!B~}+=1&rHPUfgj{XwGo zotXp#6|Jq7-*J6s|$0F^>##= ztg6urec)plC6hslE9*BZ=*;hDPi4?*s8>y$3@;nq9Uq%FLB`snpzu_By=*7V{fQN- zcXadm>3BdSrHxHjVVrW#3|$_!$=EQKXVf1m!1~AF{IOUev-ky;%uEB(?}WlE6i%*z zt!?shqutz~)yb@?Y}NW3-M@35d2PLvleK~VcLe{ebJZOEj&}=(BYWS^+ah6x&HQr&wt z4;4GDyc!cIc9wU-(F!_@h?tn!qb=6Az_76C17LgSK%QlABR7%Gz+)`w{Xtc=ft0k< zZ#xCEE7F(Lt$OVj^_g>e-Nqgkq|`+aeZge6Q1r4|Z3tgu8KQV7gRSvhm66?iz{D`0 zf8GfdL`+CXFsF+ksA!E6xn5Al8kBFK0L;k7*r~rz3I|RQru6%Xl@_-nWhyKni?OR^ z8jokQzZKxuUuMl!XkFaPMxe!9;;(s3xovfPACon)lMI!nmsosQN3al>K}d}@VzLn8 z&H@$pbq_aE_UZ+Y<;}?wT>OS{A`^eDsk5eCtYmQ;@+Ome&3d{9y*Ah9_QfkC2Esi6 zCl|6DsoY@NE}K6+pa`mClM3FNDK!yzMdE4naGGqLU*Oe2F*(06K?b)`7o!@nw>KR0 z{(h%)H<_!H6N4V&eTYF$Mg*BY$Dq#QN1|rNBQ#iWTbR6mGydmkf(n2P!2oU%+PeUm z9TJ@OnQ?%T5$`9hMz(H#@WHkH)>5HLAnj&;fo)MUc9p z8`dAM4~!(f&nHUk?Ag)ef$ohQoQ2WS^(R%##R=) z^<$}_Xu?mMXaY_Z@9z9f#=PacOj_mcm&5YpQreS5Dw)!uLQ-0z@4yjjn<0se^tq}9 zvP<|eKK719>i4Is!4rKm%a@jr#AprhDY0W-MWm*SONFjwz!Qdm3FaK0pNLB zGpU#Nov{I`B_!L`a;?`qXwJ-;)su9V`ANmY4Ew8ceIsQPBT>dx)xF9m=m~z~8+WD* z0d#xcz8!x}{$H+e;OYEKtM7&&|7xSXkA~&GZsgiUlbuiF&S>(O@V}9kS(w}WH|9Jq z9*iSM3?jedw%$IG9SeG-q|6;w+l|A=JH*4uF8lFkbS0Chhuz(Cr6*jAp@$OBqxm9( z?%D;vmjgoShB~;q;uRY-wq7U}0j6fmsYcNeL`yAA%%3x+(GeI^No7t{2JbWPc)A%VDVJlwKg^}9^E7smDHz#iuBBoA@~ilq+bIsvW9LgXS^X`?HK$-W8> zGnz?@%3v7xaP(}ne)tF{)|r{=Xae@jn=YUJDeNQ5%`Wc$jy%}Ey`c{)W$T~0SY?Zj zsxO5@m4z4Yr8P$?R~)g2LZbqjKM(Zf(-a*v4_V>b{IT)#{`>(Wz{lRvZZo|J)f402 zj6_>C$T3Bc5OUfN>$I2>x+*L#MnwxkvF=_Enq(AB3ZVHmq~|k;PZO;mX2>w)6c&UXT6|JBVa_e4CLpuk-X3N=q5SFTEDD^~)z0#u z%iU_tL=6$uEC2W0%4bxXXQs6%no_JNUD*Jd)2^25PHL%IU>?KkMRPbA8D2qI0&O{@ z)yb1tH3@KApcVwheXBfuF;)<#XL?k5i9A%kbP8n{pP@=Y+$?6yGR=Y`$g*>u$wvHQ zW~i8BYv1tg==hg4A(S+_WZ*Y;2g;9PvL+0o2%_eR>4;)bN{0loE+F?$^SS);vb)`f zp90C#X4cYhRZeWR>Iu`sQR#ZtN0YG{|D9`+NI4*btDSMBpsz#NY(*n+Y|NtP9@4Y6 z!|{7sM#e_1!Wh}d!MRgg2J{SThie712Au0{KPa+<&=5ngf)ae7XYzWASf~#Oa0veH z0?rm2ak}-8DQM7L{@03Ffx0I)u7pq)wQr60yR|_8?xNezFd{fXM_Zt8`ec<_Li#Lx zBH_ej7htW~S}w=?^dB+E#?wA_;EDOE<^y)OW^eyB4LUl2E34U<(50^CNO#!@ownYa zP-e+wRh&Wv*B_vnpUv+kYtZHWGP=ARhFh*Jt)p&2Bj9nn&({w0X?|XB^)r{lKnbYf zC|*KpV?D&rSmWo8&9bIi|4?72VOXCzD04a}RAsOVXJq#Qf6|};H1|1?#UsL{|NaBN zM;N92yT6z^is&!a+VWH_b3 z8j9TZmM~<4C{X71Db=hlm}s3zg9{n1X0N8k&T}`z>vO45XE^z5%iwV-qGt_rVAE&M z{BrZ%|&(L(7OI)oNK&ZEL5U;!rX)pTwX0bxLH)1P*TwAv7^DTH>(juKlICF z$!*n(1ADC8dVU*mpRYCh$&3!p-@xUarl2ATo9-R4|4h^FT0I9bCsrfe#m1Hv=_-1Q z8l1t>8V)J4q8A(1GJ_}`+FkPaf$etPX9!pt52Op&vQfU%PCh$Lpx;Nbs6~lXPYUYu(@k!&XLnm=99JCbEehQoV z#07`cc_TD{?AsG~crk8}JMS&0PX0hQCn_0BqUU!sX`fnBY$~5Z$&2^X?T`1)pbuNLXa*C5Dv66FCUX*!d09to00#zH zgQ7ywiM+Vf{_d)M<8ANWssIc*QCV_K&H#3uXTf#E0lUtz*>x^z+eItGY$I-#t{YZ> zg++%*m?1_}hxkzYLJr5xR=-q~9yZS6VF0&`#oOb>tx2pc^48Pb zlnyIEp~X5J**ik8W2Mprb&Ja7G8b$%Z`%p3kCJO~_&Fe!B9m_ByMjF#bI0@f7l<2l zLy_NSayv3!E_=TmTyT$P2=1}4QtBjAxp0{amX7jq{UcRKqX(TttNh7`*4n^eq4{J- zJEp;;Geghz?s^@A0@ak|5@ySm&XI?!^0JV8s z!fOLhWQE>Do!3ukb+M06F1OgFV;lt2?$tHSt;BDXIGOZn=w=q*;zi!0(0$EI=GKyS z4V9?IDber$d)XkZJSdj<{UPH6DTV#T$*b_PWkJ{m|n84n-4Z;h(mmJI9 zIAJ2)(5>y57`6pgNy+ZdPLZ@$<^N>?5<%g)wqk?sv}RB&eN^KpKY6*DUmBEq+)J-q zGcVGY??f~y!lA+f*qAD!3t9OxYPY|v>Sg_Q2Cf@29S%%D(Z9S~UW)U2wEEePn)N_{ zby3!A8AGs&2Z4#9Asu?jB!~>-`^E;AL7#G3%}-k|R+JjJZMatT^oU&S2AdvC$M-KJ zCaOa43L8_%FyzJ9;I&bXm8D--^u?l_Uf`Oyyw>@H9idk zp%6m)_--LBcx6Ib#i>CeDoCsQ(rz%_sXGoMA$==R-b$Gyz0S;O*VWXW|dXOf83*I{e-&NS{6p35ZDrx3p@QYDP&v9U2VHIwypPL^=D-cq1cI7#qDkx-_Z#c<0R~i*aS}p_z zGRq?H$(A~XwEc1_PVrzB6qM`5bMyLWT(anTIn=3SW77Lcjv%kD?%q^!W*E(is@NGW z&6+#GG3G?^xQ<{R1xyP?RXXmQno}>d<|~@-0SD*uS-rR9=eFNuDXTCw_8<3>7zp`% zo|~#R=CZBh&bn))W!Hy-iabl&tjQchEsR<-|SFj_BGpkES0wf$^S zJp#J%62){!tk@g?`S&HN)!RQ&wbHd=#RpM3X?p4>Ad9xam9!{Bpt>*!%6@!Hiy{#` ztq(@Dn6O!_SlN>B_-WQYY!5HU1^m_D>GdR(6Ccki9LP|x@j((&^_P=S9@ z{J;zIC$uY{l8B}xoy5=ZvJn`{(t@xSHFZ`j2`aA4T~H^@=-+YRC2Eq-oVY}Qb7=_( zEH_&!Rpl=C{qS6I1pulzY~1yLiQl zGF|+%WQ9>kSalwEotRsjpOYPl)L(XCG%12mfjwW10o$WrjUMpX#A4U(h=+u6+1b%# z;gR@96GVJ8&{84+Hb8Y!VY~6W8CIVqRAK@hx^zv@*}&T|(izxCQtwTc5m&ASe_aA@ zB{4@AF)?5}GLFt+OYKi`k12K^TW`$^$E*JBh z=2VefyP5olc51~e5(bbee~eWo+RJX@G^`70HpDm4(xSxU)PUq8%@&KP14x5Z99$CSdALnHX9p_ z(Z)JKV>>6#yYK&dJzpSK*4cZnHRqV)Hx^OY7by8`e(KtLTK1^`EEcsRHt&ul7Qia&`=lM})N1HqMOk-HqPJ$fzM@-23YQ#FRH zdvdjFi=6iG%bttCBkHo6?sw<{p>8(rAPHP87g}`zwGuK1v)># zf?XxBfJDhu$wV@B(`k(dH}A6|fU?~~FYUasF3q{Jd$igk)uboI@djP+F~|<5lja?j zA&RRh)nd0S6CWS%Z+boT^z+yG=7Ya%go>D0i2u=_@u*~4B`3g>=m|-v9jaTtV|V}s zh4u*%-*Dpk$JlYrev3FO0ple0(BRmdAbdU|qIn515h_a4JkX7E;BT-yOM82-KqC}> zFFm%V8R(?WKc1@`h3-2OZik_wX?w7hB|A{grymYNddJFwLki+J9b(fCfrz8(7qyzHWuI5qNEofG|V<;xYP?5u3U*|>qZrN;BDbI^kL zOzTM|gOG}=A`69y1$*m4310oOOBJwTm*t4b<)ydLl@4XAtkXlYQ$*eiQZG90%QgyCc)6Jl9J=9QC z)UJ$aa|- zjj(S0badPw0Seb?!DAsF%OcVRLJXnxSpgC0DD@|f)pSt6G=34sje1G8vr&urjnhU`sT=iRO+dBIWL%Djw3A)d z!d_XK?J`d%OEci!oB>#vyk_`{NJP;-MkKya3^{QGLs0}#l=4)pX?K z$L(S&^6z_%kpLQYBC>)miPf@B3XBD?*BvPCtKG7jXl*`MauS%rUz&%_O#i_0uvJ2^ zoktmR37%N3$(%RN6l&sWLy}0HV13=C$$BmWa`if{l zNW9}R$__dmjYq+}unXA(|53*VLtY2o{@o6%X}vlCb)T^l9iXnzOCTe_@TQ@lV4KG@ znMA15VpiAMkL?c%x?7{99QP1EDdTFdGmwR7t>sX8vrTLgzGato{TreXGDHE2$@*+ zJaT}9dcdXd917B53X`eL;$i`x)sHH{dp__Lf2tylXrO?`FHDuf^{&;cG%jH`%W|_cdoD>X@J`#Wa z*=;#G1K6#PD%dkN`e)acJ&9mF(Cpy{-)B4{4q!*j2sx9zRa6QvNl8=ZdaPHx7OOR1 zW}Lio&{5niVBq16U17BQ)cQQf_SafMDN|0HhIYojkdF6fq67u~$cOx=T=KnkK#INv zfCrrVrO5-DZf3;;K}bKmz4i3=>^fREJx*O6;%)P?);zqqGc3_X6Ip1dUs1Hk5j{H; zg2I1*A7?amriAbbImD?G(RSj-tkhNOjZ}4!4`$!KR{1^JSx5(Ja&y4nIrlz2q*>2? z?y)?Yb0oXg3*hleH&kZK`QrhCC4#Yd;a_k2JtC#V+y&NpO@6IY-&ojN`g=HI=#5CV z3g>26CtL9Ex>pKuW5n=1`S<4(OWZ2WYPeipx99NjWLjavW~=R6z+ehFU*(}Rod&7- zqatQL$EECjhM)Xy>tU`HeugHu579i6h%c1}ad6F~#Uk9g@@MWrn zOG2pC;fb$+DpC$EBZfWdiS>*v=jXoXKjL8KVS=F|mgU7BH;qYSG!pO0g1OqTAZR!y zf`P>_6B!yLi6d8AnKb{Yw-O9Z!3tjmee@Jjy;a2>3IvivK3aSi>yCM^VuZ3>H)VVX6oNtrU=)xGfDYRN?ey{O=vcGhAoIjQ9 z;by@dtE9@ch`M7@{M%6f~Mkzuy8b>t#5&wU2OiIQ5S9=wOIa4bZRS zzm58B0$qD&tASbS42%X6Xb2#jv|}4BKfbaAfWA zodrfYdMXU#Q+AFKk-?N`Tld@R)1GvWl3)^njwvSA1p!jqES3J0i*lfGSHrN!Dz2_%)`(?b2RaOT z83KZet%k;9DvkW*Yq#6s!>Oib_*b`l^&m#Hx^LgUc|5$`y&UZA{rXGrk7fE5CapsK zTb?G@1@qV>=>px;AgNNd@2J$r?fZ)}&ke1Dzw>Y!7RcAb=@ncmpAH}N@WrKQeec4D z<)nHMF3vSCCG6C7AWTyA4emZJlFauibZ6+FwOMSkJfdpq$73Ru^jUr1^W`Jpq_loZ zEFR6$XFp;3ytNPpf9qy?lZ1;1&{4gLjvi(h(<(UhoYW;&YTn+DAfWsddYWVvX8r5& zy`ZQfI7MVmh2@*`4`%jqk)N;)yxt5=RoXrJNds9BX$awVEO9?Bt`fK?d}4inOV|P5 z{8NLyO*!tsz<^LPSM3*ynGE*g5!!rzpUbDfr1HU#6helnNls-k+sz&0{ORf#lecVa zJ_}Q5-j%DU3;O(khs5$?)6&QR?abQYer|_9cYXND^XAkjl^p_JN2!s2rU_i_FycaP z{DJ?q6qoTLj|_M5Ef))#N+<&v62W%;*YS@qS~9FIIbil zx;J{$Eh&U)B2v7cfJ$O{YzqrZfDAA$rI#<&F-~O_QcqoM))q?dIg!uEl4RGj*z>MnnlQcLen;(DnUHSA58P5UiaR zuSqz8*p;i8O-A}QF35{Vrjt_`cf(i^`v~GA!@`yaq+gYE!0u6T6#T;(e%oH>kpHt z7%`iHM?30r9+_G#HaDk=PbVO_9`MHuN@$HECMSGeB6emLyjgBc%W{$_sFu#0V4$1S zeH-8po!3b{OtS_&YD+KmEJ${XGRBdzsJ;bVtEr+by{(fb!pu_Z~fQm2&UNN zqllOlM{W{;0_C!Qs5$@sEa51D@D!d2YHo7`b!r;+1>EKU3_11>(W-B8u275|>f!@! zcsaeCYOZHmyh34EE6o&N!%nC_HA#=eD{tY4UHG9Mu&4sN)w>)0+k{B^yoqf0Mu1@1 zXuI;|9J||9@RW%Hfw&iYaO_nc2wj#3^f7wE%y5_&bBP? z|J1yjeQAV-_LX~;A9GWq$UZwukt z6LI49X_-uTUpI@xdpVA0m!fhh0erMQTS0v2*z~M=00xlA8UgNk#`6cH|DHDXcyU~l=5QYSIZlP0A~k<<9AMsy9*RmvU#v0`YHoZ;#kNzZ2mtSrU%4&y8dnBdZ|rydD)R?*cO0RdR|9>f=9@ zV0-*CT?JwE>e(kyN&vGr(j&i{_3Z6${7SgL2}6Z*JBZ>leZL&-L$P}MX@w8GYJuVr z5Qha_&BDVRU1!3kJilluv|0LsRmH3`f0@w!=KMa$BRe><(pW64*Y(hQ97m*yZ**Zq zBnK2Dck}w6yC45m%cFnzv}cwP>LfWfOt#!~nTt{QFdk!D`V!niH_l#j>xNF@Zo*PF$s$hx3kzEi6?IipDv#Gky4G_WnyQ%H)6N!ptMBgq zag`si2F{t3&AJjn`SB#C^PRoTNz4dY>D!jh_+=X(mOi_0z<^v7&8cYTPRS2@ZF1dS zf7uv0qjiJdV1BX7dF%cPRRL@!U*!BdB$V3=?3MK_e zaMf4a+jfNinN98H%kIJEv7&y421X)5rF6LjYklLEHdX8-&c;U+H`$@4cN!o_ry6 zdV>hU(xwFegTvdXV)&a@>qP6<`}|K<^Ea~ms}1>U!4@W8=@*JGU+fNNh2Q27Ioj#*sn$x4QKjKVP}{8!rp7M;sgNY8E@~1Pp@GRU zn=Jofu+`B`Ip^!5#QYaRDp>g>s0r7kFiTDzV&RD$_KgE4^{=G(m68Msyex=$$j(Q% z1u0RKhy1~gor;>@Y;<^*JBKwtj)~T6Nk`h{c%x==yB4%>{;arQ^C6pZ zin}q3z!rA#o6JC>z2H8!1#N!Z&N}yevAb(9B>)iZ1zpB*#dn#b$KUuLsV>WN`>aO2 zIk;oxs}pL97yD?t$)hN4ggO^CuzjJ;2IK7)&!=eAu`=FAo3@NwzZHKgf%MJe)J|5~ zuM-E>+xmLWW`)t)3MAAJMUljsBIA0J9<98QOE~uG7)X{JalD=RB%KC1A1bz0vWlVSnOn@iNXdRA7#07ilx z?B=7c)TzH7oHlsAx8s#o84PhxK=OXXl0#)K0U|{>%M?2Tr#n;Y&464p6@qJ-us?N% zT_nbVZ@WQ`>qbUi4^uvW0^t7=${Y<2B8!4SuI+mvv}A5IB3D@mBF2Z(@-D68Gt;^u zgbm&hh1Jm^z+B3T*|^cc4;*iZ4Q&UT(|sWRb`=2>XuNy9FH6E1IsV_iNNp5(pIgQ2Kc!6RMQAC#viY7qfT*;bk%~>ikj~n7I`Im7i$#LAk8R8pXgjf& z9)=dAkj?G97byI6Ie*AXy&R&TF}#f(S?6}oD)i&1bYKQK|NaaAK|nW?tjv6lhNh+& zK__G~C{EEyfZNPsu&2lGXCNy>RpnQ8uvkn#m1+6=RoQ5{(8J?AIH%90ktKnbQuOs-j$Um;JAH+= z%VKA#+D3?a%?H6PKpQ$;1XB&9%OE&z?=0|>*YGb3Gmj(!pjfZJRfS_Co^-D zcxo8Y37J(krf`~}(Xz4b>792T3_hkY4{_+9Ojh!o6!rC=3Qy~ty)H3uQI@!-<^LCg!X~x#svyvsK=8`RaWQJr)l;MY6l<3-GEppc9fOO zW>|(hZ_QMd%T0FOW-G<@P1@FFKz;3d{dU8wge}HVh(_8u`+nZGwQRx!Z%VzcU>G2J zviRoPq7t?^b0K{q&7Pd=Sl zzt3)Q&U#o0gIsIDyBax#?}coK}vEfww1#$R&gKpTTm7i~3@L@@*P zo&m!gQcMjv8lb9+v}*OOgdbLhoV{#TJ16oo4}W8K>gwnWrvSMC9~7mre^${z0vFd` z#oGp#n5-3z^LFLt?Cah#KSnn_g0xlL&oi09n>aE@l6G`z621tERTPpKxVVA1xW_%> zC1IPG^bFCTNGoqtOL>l?-6d5MsunQREWyrNq@LN!pX3)aGdc9u zGrX5R=ydLE0bjd{p}y3Y=-%QVdoiY;p&_`q4-kC3?P+lwV(aP^y3umAC}-Xr_xs!m zo!(#efn9%u*r;nZ{OPct?d}UhBDG=7tP7eW3R5-Z;3=&VdxUT*gu!LjyZhP3*NyqN zNOJxjP-Pl}T!pd>QghfX`06=9qjogX zm?tRu4dOkrnUv*+PCEubUmq-pR#owZM1xl zMJS?>2)g9bkvjhTD@`v`Ap7D;#gcI^LTb+uVE9ZgKr@69At42sI&h)l8}3dmA($OexX4U zlc85FIw}Q@bjOR~AH@`<(_($)k~Q?+hyVEow*q?3K#TTE{{B0I^^z!`sKkN}a-oyy zXb6@vswy1}zkOz%4RjB8Zpu2PCkP0UMoA*L*xug_MP^gy@#V`R`&U%3LYa!78Z8N_ zF6mJO{7E6AFE0I6CtTIB=x6{dwTQmCFoB;(&SoOBa(LUTr%XQ+mq5+A0%>qTSeZzx zZlI^fbI6aljC)D7i?h(dSCB+e@{_|qv5NBR(15*do9veo4cRY#4>ir2d4T~YDi=Y5 z1m?}9Tt0?Jt5SuW>sfQ#KpK7kpY7ZN8ah=mwQQ4W`adW;S%_t`hBZkt5rMJ53EOFLbQU!kreFxzPyP7upS!az|2e z4F(_0sFsJuy!x#byY5$b>@(oeY!?I-g$Vx`I@WvN5XF&|g9%!^>tk zr_V*0P=8yJXktEAA5vSBKP!>6tTiosZ_A}GGXi(wsUrE?HU92ZJZDzO3+h=w&U$o#4M}z*@i6p;qUTJ6< z{djL{oD}}pn1Kyogq%z&Q~m1gVIS=r29qu6b^rM6qVWBtpDmJp@Af8zUJf>q|6dF~ ztLt$b&sGM&MLaGOm`|!H_qeFv!Igd)Es>oU*69lv>c2e z0ErvVS2(l{KI0|yf4r)DHy@29j)p~ql+#s>qLbx5 zYc08ZVO4%Ph#9xQH$v z>i6KV-RkJKnay+A{L^`}JyRKjLYXv39!tf;ETYSTg={wElk$Yp*8Mb?*=^&;#e&=& zZ(im=ln}<>?T8DW@u}+Jmbc-t&Sf#nxtj*2aZ^U^Ry4)zF_5)LIbX@riUc z61z1vh-hUsnmOSo^3duBXLF!3g37Fr*S9wncXw)!$MH4JS)9Ss<=VF%3z;9L@9(Y5 z1m^uXmet0szy9pjBFT}em(LJA9M|v~UJ6G|lruzxJ3;u|4QXp1MKRMgzdbr`RLsFG zVW1O3AmhLGNI{wy)Oa@v8#{kIKLRLD9l&)~q|O}_+m@uj&EyDmU0MTY`1wKHI&8l- z)cgRWA(BF2vz!?Uds=FcLf(-b55|m$38&Q8Dy**QGA%k|qqn`}O8%jf1%gx%;kH^c z3MIavw#qvyd!rnc1Z<#eN)ShqwTgvl_zdencQ?zndV6_{{!xPU#Z~g3+f;@Mx5MdP zwGP+tV1&Zy)oG5|2fR+oAS&sA*$)5XS4jOfr^|crB5?<#4U|E@i7G)F%W(0|XC@Om z7mHS7*K`ymM8pflIGZZNG(#GByqA_~Mn`Mq3^rGq5dsX^rCE3^2=Ge|ud%y9r^WAW zH|xOfaH^szI;fCFuBoNOO&YT8-h^B=-~S}KJF2zK-2W4M4H>sFT&0175()4mwgY&) zy#&h80EB9FM<}tQoP8^05VdBFj^j*zputmu+TQk5hl{G**~XY2UgtMmNI7k#66j4C zs~V9320h@?fLgE1JYg=|HJ6aSzVG&)(V4VHf~#v{JYkJKw|OC5o{pEoAGfeQ2)_fm z;dmRgWyea}gMuPT$uP11QTz0Gy~_(^np8sT!sED!W>gf>C56D=8V5GN+2<={ad_Hl zF_LC<7XNKi&gb)BG?^UPzpOLr@_g)Y!;g+Ga=>w7S2Av`i_HS}o-6wPnsFhKq7gR< zFyA3pIlxjsWB3oyXjaZQvKO-tmxO^`{rI(UR+*o^RBL?N?KXNr7=;uZG&zQbl*?`* zhoT`z**(TcGWfHg$x{4?ma{y+UaxfS%D~6XadKutfSxqr88=OG6XKzM_K-P;%i-|~ z{4YmIgqAaf!`ri88;K8-iK+RGz+nZt=CE?%UY}_j!zLU1X+2Fiv)B)r(F==8Wsl2^o`FG0MhbjEf|csk@GD`e zDd2Ibn&CsZpb7I|5vYuDu^QKrY_{0H^zlMN`>nkWqplie>LUsMm4eVd-VP>5H*9tS zy?9-0%*4&Zi1hF>JISQNx?DOx`DG3BwCDR7~t)9XLadOEHv z5b>K*(|Jt3T@;&>FD-QQ8hgInRM$TncDnPTvyIzUeU2(Z^={uGPu6;u?-AL6;fRgS zfJ_n1)b_6vyPG1MZ(FX>^!YVy)l9Ih#=7>%v-!PEzLBe1o4i1R4$9ynN0Y(6Yr_YG z6dsKgo2nvhCS?772a(k>d zn3{w6y)F)lV^Ik>^^^@oeRnyH?4gwu!Jcq%rC~DWf^l)_6)8HFj9THENz`gJ-idu{ zeb;L%?B+s7_JvzL+%zAy2xHb(QyFiY(ff>kAD-80wvwvs-s`50kd>JA^a)R*n+;1r zV!AjIRdx)RY=c0mbu{r;oBmF~)GspiT7=6P*y*vgvFKB z!sITP0xhXUS5mfEi7f?(diFAjjCvxuHLF+~oh684 zP7fiX$n=|kQ!H?9Cp%ym4bdka)lQWvZ-(&~Ay+4)#Ne^?>XvF$Ek0ew^S#ZN%P6^OUfD^mRmDze#00T5;_se6z{K%jLbL3-_bOO6Djn(Unqk9cIc&bjKb*P z`Epca8k9C$2Vx3Y-YgiQlM5labbOT~#JGa^6BQRHg(eIyuGbluX8;=6dJYix(L1_Y z{7|wR6gtwZ3px^q&5><1n>7f${MgJ}s#0vG!Bcdw`&phMjGp#v?bdh1=QDAq3cS*j z;}_azt*}vE8EARY&~!#*+Fp_B8&9XNeg~6lp5(m{B6t!tKt`gl@1Fw5tq1R}d7T_& zVVRDS8rtM!ksnwzB;dFk^glLxy3k$u4T&<2 zrv#A<7D9mMKqMtgkdKtbM!paghgTq0Y%taAj`U`f#s zK-->#H4d0Je@{0CcY9y|ESmXBEA?E+goM@bCfdZOe!{da=A4~u-_GpL$>1J}_!DAV zf2e;`aF~>RwiOkT^1l|4` ztjzVNl;3%pnn5w2 zOt?`Ru!RpdMk%^D-r*5a{}vt88SCiBhG$gG09{B@Vr4y^y^u6^Uo-Tzid|hmg~2eD3Frq5SnVL z^6)w%Ie7eweqd72zvf+%xheDND6;$xexGf>e$`yE?N9-%*Jo?nrP52DNd(@+w8)q9A5_3Iy zOU2Jj&qz%+3DQXvSP&)?aaC3Bj{JP<(XyG|@txWTTQEA%t4I@5{_TIZ+Dz-cKXFg) zxcaN*5CB$?u^GlvPDnsCqzAtb0y*-WcuoXx^RKUqU$?_ogmq3{N6{hEgu*d#VHM=U zQ@R)4rEH&Hx1Zq6~15HI5v_gxSSbUev?!BZCwiT(D|&b5ux zfcKv&juZL3E~heb@Mks#wIZ>!?*=+LM^EF)qxZ+<0auPLAtH%|* zQjR$N8%IeAP8bgplahqr^WrHK9<2PWV*w5&NW?ZB;Xh|iN=X|Lf0 zl8;=Z00ny6eS|V{D1t#yJ23zZYC8QbhjSK-v1W>iS?Y8RocSI%(Sbxa3oj33Yu2|1 z%soJ4QzkxiIR}OjRy2+7aCDIgyJ)-(k?`LBayth&s@U(>Y^{ihmFg#i5GlHnV@kbN zhvUim?m5qc4ECYm&w$nV`X*=iMTQPFhsQ4=G(4zj_ZUagj*>O zA+_N65`|fHH+|b3>0WO=KmBEOf6@!S+?tJpMXVc6qKGO&`fb3L5Rq{@>Y1Q~KRKj} z;!N~`4FfqRSwV+nf{ay>1e+DWwta?lbLn+-9CxE^as?~XYp3!V-rLanhROGF+DoYW| zQl}O%V`-X=Be#n(uV^^=1=R3NjVQ7NLvURo{|zceFeOJEE%I%kf8dQaED}8@hTSvE zR6w^8oyTe8%DCq_Fc%PM9{$)b*BuTN4U7E@p=dH=kxhcMYNu!yJDmCvTWQ3cz67EJo+E2R2OF(`mg|MelLoI$njucO|@Ys3lf~ehox?TG^z2^P%G$&L3it zH{~w92%n@jTtrH|8;fjS9vu7d7;>BwQnS)JJ(3=6)Xw4?`>!5u z_Q3nl4RAwnbs#%??@$@peQFf54q}8TKN?2cAeCAPhWw@s(O{DWH?Dz$Fc#=#UZ0vQ6i8BV@C#xvlQx#{f$UwRwVX3xaGt+GfYwCx9ZL9hYeMC}Y{RMD5 zxJ+lokvp#a>e!mj{=mbA=t0~s2wus;fI-f{3_tp zuj|y9RoCZ^O`J^&eYSy0_7lmdx3@^Z+l4i;sm^} zjT|8@9GxgR9zjPZADd8BDU&84wLSj0MVQ&C>L2PXMK-Ubk~9?4Bk!kvrY6RPP!XCv z8;-FUjsN|?H?nOri{JO`s-CI9V9oNSgvS}wiQC|P?R8miaBcW_Ja3Kk#QXX!2fK+L zW&+tw1*XDU1nJZW_vhz%{Qap^TGh#3%ypv|d6v$OI)eHN%APwy5+8ORZDOelD2mc*$KI z!ALx)^Bod7@JzGC?R0`w(M_v_f|-;lme340lZ@H80M&1mppmWx=r@=Q9HHR&0RT(F z>{RZHWqqZ(RqXTOlJI*79VHWj29_ljK0a+!(Qy0Lz}tzEIfsDYv1K8#&u@nx*rNSe zAeNC9zn1f8O7Z8gbXMbcr?kA1auSk~vfQuV3MYWeF06;8Mi-JCxD%WF`X~r9euU+m zD?f=k_vE2aeI|fR$iv$vm!YIlOrk+L@^4QI3&SbaZ*_QT)47Hc#1JE9hRA9AJd~m> zbcxDRtOr$clR!x&RcqMw4g%hU!==*%`4kFF^9siiL+g>mNzkQ~XZw?!c@y65(NoaYH}uB7XybzW+b3@-q84T zqXxM;7}C_xWAt@m>7EIlQ&AzA+Dkx`D&^Md@q7fRfHyW183yMv0xS}j3|FuZSN#rd{U1TqhmCxQ(VUT3!r+Q5v2#zt$i=EsRC4Y+ zv7wQBDhW)-RwlR0I>TEQoXdDlkp6my6VU&!^LXg>nTr3f|Hp-q9hUh8xy!)n4Hrlt z2P%kOWWK-StWu-H?-iwtMe8VLDvg8kVA@kr)nh2xOMP(e7V=xDDBKQcYu{3*TU-!= zE7dMn%zk@cYdM7j(gu4erXtJiihvy@rvBELWBPgXZDzO2ppdhKzf7+AAP#&Fz|;E4 zpFQeJJiAwSd}I{uLC>iT#gRXieedX4a3~$1H>jf2`tHoH`ma!M=1EbUPN6TzuSxJ# zpfp94w3$+AGC$R*DyqxGWi9@q6o=3CrmWd)-*^i3)KBXGYqyvF>0zCJ#6u?&$k^=< z9uoZYY2>FE6Qf}gPPhzrHuULJrxTgSNEf6QpTX4kmC7$)rV}ew+{H~A0~OT^KVjjx z2Y-c3r3pTm!1b@!2iNU(d6K?*1TZQKqN*vaGA4ZPaC_-(6aM%&smfEgHT4tY3eM(} zjhZM##NZy@&r4iBa!B*o`SBkWO9i5!wnqtPzwWgg_St!@)Vo;PJoieK2^Pbqn-T(FbCX?*U&ve~#uO!J=28V10gn zxyPRnBIa0dYPowHK(-ZlznHH4BuAFn zXo$xt>|ZtaGxDcG+>ba%fGR~piA3h~?t2vCd?2NByS)aB!*$j874`V(0yAl zO$TJ)eD#Crmpkwwqc%Yh1{9GA^7KWqq&>YmIpR|DYP0n$luF%tE4e3Hhwr_V(e`nK z;v~9Zn(kCpbK_hx0UMju3ys(K_Sihn>9hsQOO3Abrl+-~4`6t7g_wO(j2e}`MB#i%0 zhiO8|u=bEh4MPclF0Fewp5K+3OgAF2MmMRn)ynHY$FC~=^PSFMwye!zHQ)KAl^KM` zV(^8Udj^*x8cQBJG&BttB+}i9hvUw^{?oTXg~o^t5P^@vRfHlN`%<1PUZ`@;c_ZRrrOcpxnne4 zBZ~9|zTMw6Lc^n+krLz6szi&%K+iH5OcF|S_7!HIjw)H_s>XLf3U*o#FF0~%(pAN* zIdEeU6N|^-(LWXdU!8db8tGMSIM4?8tI3_umj^y?mgi69`oBEn8W6-tEPgq;Bo8Vs zE)K5!Qydg|`MhJaE5bB{!}m!7pb_w*Nyvn1wj#-Uo2k3B4WKPI>+XpcJulb0ME=^q z_7jI?Z_5Of6X}L(c0TvA|IEr_cQ=}(X*jc{>hYS%%lQ(3)qKQ4GM3`Y5Tsp zi4fwhA^feNi*CpaE+^dV2lUUUm(*5PW&hn;&zL;F3tsKDQmdY)uZaCwS-ftW(pv$| zHh=O14QJ3b*S;uXOQ>3HTweWD!aq?Ly=m#!`eEcRF5cmG=pIMTW3>REyP2Doi_14W zg?{k{w1An$BT~|%Uo^fb2E{^rr!_?j!hU*qMqf96^%yU|O-~!)6~(jYvw=u~mD9%3 zYwqM0@LxIpjA}#>PyK5PSf6B*t=}lHzT|cF9@tn{{kFQ(Zrk~TO|Q$%$yqcbRN4N= z-{?ke9iV|&7cc_HqI;Ge=`ZLH+()a7<%IHP_AN#UEV)z$pQmY_O#)IO*FSobYJpXj zw=3y|dCv>7`R+O`R_8%mEJh5-8<)N($rMd|C%rGNe>t>|e4#iPhpH*N3^mFD&A&^( z4L#Cg8LP@9v(4t(b!9w%R!YG8s-Q4PGIPrh2F5Hzhq&BBQRXpQ#fAoSO|ujd9+OMUyfnEI za3u|0`&KQ3qpcCw&%kf9d4lmkfadXZIC3AU&+bU=zYPw2SWVR(W%BQf=D$ylwctjf zN=2kf{@SY3)}Aevufwl4{v!&TH}b7?3=M@3$SH1LXu?t{Q-#@R@7(r=`w=gA)i9G; zn|G1^66W{zjaqx;cU;q(66V>h|A|+1T7I2M@6>jGe}8D2<;5*CI3HaHN$|~*>_gHe zmBpal;?L+#F}$$%6&nPTyK0K=3HqzXV)z*vk2H@k%-tC^Iw+s)AiX@iuKHdgN!}lF zr{oTUA3D-#_0^~yTXM5KHc+nem>L;4nNlLmkZ`}f#b27`2bPDT-(-gmi5nIOn`!u=nO96zaG zZtlCUA38Wb>M0=uQDr>Grgn19S|5FJrjX5(kKIxk5`M?vzv*>L{C!UvR-2!SbaBKg z{XZ|jwmdnz)sZ$+cwXT@X#_jt*&KErGtl$><%i#?l2JNT9V1<*CO+fp;nQd1BHb4}nrOOy8o`d?UcW|_uP&CfYl`NMCVO=q=K z1w1cwT)m)^aVJu*)-hBKo7o`>d^;c{MX#ml?Y!lLi$zg6!O$|_Cn11@730ctFqkRC;L!W;g1kO1n=y+c{B28rm8w?J zdjm0)@2*U*zQ5tCwd%>f?cXelbye~S z*H>W`(to*NRAKnEue2K-@DRc?67xW{e{_4}jGk26Haq_IUp2Z=515uzAqBiY9Rgx7 zChd@&7I`kCaUD4z1|xBoqmy4>L_C;EH~Y@FInR?YYR`}hIjlqpv7{rfI(XL5Whts^ zsHUAEqCt4ZScU8OOq&8aeC|#(u6rh33S)w4Cm^EFBL%lCn3pdJLedve9i zy>U*)=`3Eq-*51$`vf(7JG1|PHG7#MfBOstz-iPw(?UdL^jWZ$0XAEWS;B9wc4`g| z8-Q<}PHfsY*G2l0y1sib`Y2|Yqtr%z2xY+qi6f9%dK*66xb zP*a)&aW0!EG=7@v!`=k_Io|qU0{@1FN88iu@K;P%%x?4Ox}WU}XG?NO5hKfB*KBaU zn~+EGGP|P7mc@xR2n)7BS^mTdH4@|+g^?N!^%pG9x^6=DCwx1HfIdo83Ouf0Bjse?<8bfH2Gl z>M$dMMMV%0wRo)~7Qmt*!rwW;l}?K*#R|>MM?t|U;yOWY($LcUh}D|&t6j=Ji&?oG z`RFO=APBan4xMD~x0^?hH@6ma#aRgXq5mz2?4VC{dz^YMqFo3JfR5D$F(kQ} zWT4s?QGv;i{bKf1g{21#0V@B{sfZzjCae+Swq0Z+hRJ)eJYJw*jr*$Fj?XO|c>}QL zc_?@Xs%P8#XSVW)V81MC$CSqtu^$Wp8_e_dAbgG$W(ps$we4%gec`!9NmKtl$a)P0 zJx7>6DQl_wIjn*~r9T~oKHz>D5E)il9dA>CjFX0wN()A0y*yM8S5%}f#*_04vEK}YJ{Q5XOeVtAIOjaEKMhJ* zxpfS!1!?GJPY*mkKIxj9m#8M#8#N9#yMvy&(qnKYz?4ZCbZ(4X)02Mpw%NBA5dmwP zI{%NRYYfQiZR2^_wQSqAZM$XLHkOy|+LN`GYc1QhZ7ja$|Ka`8_os8tb>G*ISJX*h zWgOPi!Dx_Nj_dR9qgzX^;ski9tsuHk`yZ_Ha_+Z!fP!y>~`~+JMxTDyjKq4npIjc0e&d&yPNNoUZ`R*Auhl)lcjfuQmMC@ zqrNy~u)pT7Se7Wpk zU?w|)vgj^4I+d#xPIbns%{D|KsH={;hqTF-FT#5pNO>RcQ@6(JjTZ9V7i&*my1ygP4gGq{wpgcondhzFY^KE4N9 zw2~8}Q1}g612=AbZlNw(Qhf$%yM_Wb)Q%7Ha-Q==1s?=E9v=w#?EivEE)X=@f6Z0O zn4nr)*#UR4VSndQ_7H#(27ltGaZm7#B6A~nudQT?%)?*>2xq_f!ahhUL}DT5Uz*DA zCze>LN)mtP6S!+PGSTpl;N^-ruP#5$8QsO!nr%FM4tC2X>LEXG+)GBETgk~Po$swX z$^q>DO~Iv2E;c2u0jw;M_bWf<5V(6s2X@ztm7Csy)D>7FB(xSRbS5Ne@5G0~j+iw- zN>~-psulM1?||_wF+_z7KVuzq5qW!w`_&n1$;VKD(K-Mcs8eCD%OgR@t?nFobc>E+ zYrdCp1Mc^~@OcBDyuEq43p0mabg*#)vwg5BzQ-W4G1 zRa>2H#O)>Ye|!FEl}N-VM9xQ5i;EgSX*=}l2OKiU8e3TOLa$d1dr_+7#Lnn&Ff6KcDATp2{5X=^_)YkW5kk@p8)b7>|j)- zSJTL5b~@iwWV#?l8t$nxy3SC=@-OheZ$D@}>sOTiFM^S_fY()~t-7t{*THyw$&e?* zIqydr8XyiNLX37?Z#(ET>+~hlqY#fe;PxlMx+Kv(WqRR0-TJ4GCKu1Ox69Ys%Odqq zn%|>oj2;cZ;=w+s3@91pY~(jIE>@bPU-o}Y$q@?r+E4cnS8~HihD1|MA9b%h!C|&{ z53-n>s_0d!guGe>{yW$RCK*bfj~^6JZUWgT8Y91+-2Y@vMj`4pXZsC~E`COX+Y-uKZQneVQ+yn&#$fm#chLA%F=Wqv2msDEg>QM3)6R0h7P{_r7 z2ySkC`JAYke>ffMbd<|}-TwYnx8NrX1qZ6KHd?W5!#By=_bq*=Dc`HGA_V#Lzb@U+ zj#@StsS`kD4PlkG6K%lxa`GXt(gaA(4c}##L$d`ri$z@Km=4jqCz?THUt+ zf2$V)t-8OY<+$XL@ylpnQsDKZOICwkJ*E~ z3^*jx>1f_xVO0y)8NG*( z5#Zr(6XP}AB&CE#-2nCpeW}j4>UO)YKRb06Xu*U|!cJ9^g#9j3R=@;LjT^ya66bmZ zpP<|6y;C@-cs4afnh8FGx{}v(e!8nkE);>meSkso3=G&@y|O`hGy|ILo>b{r4Qr z1HPc|?y$v}R;Shn)Haa&(6KVOJeHpE(NG)rzyUj3YAHS=fZA^pvr*sp7%ib^*EVV+x9 zug5u0Dh`upzu3w8cbt*Kk7^z%U2H1+ho!sE(PW7am!rY!4v@d#x8DAsEF_vFA(~`f z+=r}GIO(JN*P96T4$GZ&*JnGyB8`D1aP@p)ND*0G zjsBr}tA>a~YKz@JSkQWW(F|QZ)Yx8_yAJ~E5Q~;FtuhT!%b-sPUqIFbyw$CB0Puxz zdoX5!r6-&BTk)>BY+8{W%jRqtNDOej-12K)tDGs30^cYNrljSXq+D@89>?8Z|5PcT z?vp#>_5@D5Cw_6bCdZ$k(QkgJVkR1d(X;Dqp;!0EGaed8wO7oHI`t;)i|X8|j{vbk zE2kzpG$A|%_33e@H|^A8$l!Ca`jE7onYo)Sb!$oir7GrH1;SQg@<*S_SF%f3zRoI2OVWn?0eq%^j#+W`-19n`5pfS+53uVNH=2SzGeE_H^s(rX zZR)(D{5hm%6ODove}1HXb%r>E&&x2Zdj+$xX=G5-^iUHmEwxVmg^Br}wwRmQWt< z!DPxzD+}iZ+`L3_!4D|tG37bH#%y@qpp5KR?+4lduY|#B>y>n?Iz}R=!B&-a9rk3w zR214O_4;oYaWzfN&Vbin?^{8i9yGB9VW`@LaTGy)OfVS5hl#PVbS?mZkTR3_4ce|%7_-vejoBi50mjt4VC`?gz{8~alZ%p3gK?=?$m*p+0Tp4DJ!IYoGtnOTbWkv)mA5F zA^%DZ;lTUl`^?N`zQT4i?3IJ)it?Bn$tZUv=z8zDnjqQ_R2p~kO&BlPk|YfE`}zWw z8nv=wx5?a1+IaVQ0Ebe8q%kPn1`*M|>{Ay+4m}V%iGAaRx_JkSy%M)h6WTix@ zG$9F{D~|eCM6NkEwXn0Hq2XVyl9gk)b6~g&80EYut^~pQx)c$21P^B&eU^riN($$o zV5_d0J`=B(x7w}udZ(b0sZ}@|_lq1!tFGOX-dYQApq%HCOD6F~!**P)mt4-e z`wAm16m1{cu${;fyw%IHG!{d(;^UUg-(Xj!bP1KAaLoS%!5R&pLJ<-fspa)>KI7rk zl##7CoZJs??TN7|H{Px`zQQbDl;dVJJ6}Z{_)PjadGXr803cX0`~A`(!QY_YcL6;} z#|BKxa8^!|HLlNZYC#T1nQM&U_>lu_8mm-Dk%n?HRS%L9QH}2p2-h7`>r(CSE?3=~`-g5Zix;HEKocMqF2OZ!5%DA?f=t@uGxB0j9Z% z&G^Dp9Mtm>P(mb$Pz`}pSjKvA|8GT&^hlpA0=RGW7Pmm>E^5S^R>41vHL%r)22b*QZMh`FpQ z@Yan_C$aayXaVy#%3foce94BN=SMQVm&#XJF+f5)@@+awtX)*M8CCJ3uP${}Cw`rOaw^Y5Mc*3+)027M%znIV-dl-8p5o zP7gx4CZ@S9wn0R)e$z3N%EhhR$K8zU&%77UJ~+UuV50}^j7WWM?NEC(;_HuK_@~c5 z%EgZf5<*@5P^NH40iz-o(eXHoe8&_rSE+bWJS||wXhWpAgZz@P1gMeb!;dpFSLyaI zn#>_0!%V&kBv>91ktgb>MBR@cJ%moeLBQ}?zz2!#$^gQs)Sdn)!l0Xh$bK}jAqMUa zWiF9OPJk~##OQJo?zEc9-{!RYSn}Cez4F@%x+W1u1(DhQg+O|$cY00AUThOf1d8O! zYWF$?L0TaQ$zh%AJYYO#dq3{MzLctu+(NFfmqjP8RT58~xc(EC%Kue?g>8I4$ZoSW z3?=XBEOnUqaTHX&mrCce2RTBEn)nL;x-{f)K;4p@o#i{*6w{9wyaHC*C1N*U?b)ro zWt0b0FCuNzt6Pgm9j)#zv{MJY_^fxmqMXVVL;Vcp`;Iq;ZIUEmHt}29$*3y#DY;29 zlq||NQiZC>_W@K3@bbjA)7ud<|L%?==Q-5J`+jJpK5VHofdhg(1Fft}Ss9zP?f|FO zn?34Catavuo8zayqkqh-Z!~A!!#z4d*B6^!M-#p@P#bdGN$Z6sKHcph$lS|Y59A6J zod^D_d|=b!rqrHUuJw7;8J&)PCt8`4%mpvr?x6_1KTSzj*q+UMvNNjJG1tL5qK5y-Tm-;;Rq(v;xV=!RcUf#@8p@ zR8dZ8ZGV;eeec7Sy$KX5>#}pHQK6&E3!)4Pk!nyv!|!WqRU5px{c|n*ax|KDRw;Ru z9SWTSN8EtvOO@L^?~V&onmKQ~?)tYslO=DbEK40_Pp}ng6KMkqXf>~*=|{8#n5pu&SeM~w8P$Ky zw`>OZ-k+|w?G46h9dBE1^?W*~6$J8ePMm0*is}i^z?v<**0t`rG`JDR&X=5c$63C4 z^lztbqlA0`pFVH24@<(>8ptGxGAy?!U{ur!Y~2+DcBdCPbhBV~>QLqYiLA1olaUzu^{Qb-3bJuVh4ObUby)6GlOb~NW!=bsPmPfVZ4h`G`zXe6uUz;fs5 z39}sGMT$B?9tGn1WQ%u;h+#q9Z3e;YJ85&~a7Ecg3oRdzFYoKkHa`H^99`!yosE0r z+_z3VUF<2JL2+X?U(oN?as63*%0gZn@%QJpX)uK>ROSkaAp)4NN^cnCkUPDgP`Lw* zyE@9$sX?Utb__fr7Ofr)XioYnk$|uHb3G2iEQHNz;k4x?3YjP$2OnP>8l6A{{M05k zNvh~74DqXVC^8sa*v{E@AABpDl_`zX(p3Gw)QDWvzJsweQnMkc+)DLUDT&MVHZRN6 z@d?V2>J5DPBCCskiY)DGtxiYJTTg2+US~4_B*E=P)8Qo1jUZIjbO#2>F_9bBORvG1 z!EZ6R#5dzo7-_Uo*_~ohws!Tu;DyzDANpRe6bX5Kk9Fc2Qa1bdHRmRmHb43=p+UuI z;9{oN8fP|%g=nNlxvHo+7eiR>m+arCz;=p|RJK8&V6uvETc}1+c+BC%ppTQ|e{XGW z9-?e~rnGKN6O~RcX0+~G!wb!TfPfH#T8a3VNgp@ye;bAo;5UMAEETPZe70w}9NaUQ z_SF!y(c+j(m^;<+mEV?yHxvib>s>uw3irNV1uUD&WU*P~o~($g97a77R{gs>nfp;f zBm`oPWveoWOr{{uHdnd3b=a2g6g`!`jA6cEXx^Qi4E_#B8cLINx&Wg4QK7&`h^OaJ zNI{A)k?-qbwaN{!JvJ3g7ld0tDBN_rM^3x_N@L9sAUqQYtE4eS9H17M8Sxe3L})uP zeQXYz^<^KE-@bt-#7ykz5kCW{ZIIWdUy0knXxdzj2*U3bK+&UpafCrVDC~>5fl6xs z#nF=-OA(XuP}KQz7QZh)bimYrZFi1pqXokISELl}tjxt~k&B?8=jYA9*Bk|SWMqIF z8doP-W4Mnln{JvmFSn%0;UXM9`Q=R0oSnsJs!yg7neAq$#{s!8QNg8JYI`bK2gxW5 z8k(}w-8S!b09%n*)O1ELZVKTgkyM!=ELtUUAe1^K0XZedT&n*~t_B;=5+4 zb@vk*pa4CL#eMc|ta;3W0-o)}SBNr>dgB69$Y1swi?5?w+HUeG3gDO$a3QG%gAPmr zzeY8Ag-kPN4Le89!u`?u*8VDN7I2KHLvEOQCvF69qeNp%v-lq2X0-_>#X0&d`jMWO$ zwPn-neH3NVUzPLtu$)b{c0$CXBA(o6v&AtfD-~d%4GU#(6OK5Q6DM`HhRM*k;R$0$ z*bidh;E-xO|E<-1?IganZ-DH^p(xF7vEBSNvI+U6XIeu|ER}*Ldr%H0EMlzBDL0O5 zXDZIcZHfqfTQ3SiQfAHo%DV`De=6#iglS4@j^$beVW)8DW+5YEd)sCA;fhGT->s1q}$Oz7TOSl+ELy|)6bRCx0 z@k^OeyLKPb$&*lRE}PR%|7d@Tnw&^Vav_6q<SP2Cgj=|K z$tOVklX5p*RNy<6CFo?X;2x)#YF{|7i-!+(2xSzR``H2LyOF%7<|#e-{uTRa__BZdqd z!x6bHJz>=j=gp`3+96Z7P6#a>~n*%&S8#FqwZ4e0m*86H3K{M%bXkwLJtb-Lo)dgicy2V#nBnJ{M*Gri_)+ zg&^k@bG{T!JUJmw#y!gs^&<8^<v_8wvmT)5g*TAKL0-=5pbnYo^E zNSpf`$Jre}#@{S}|Dunl!Zdeb%tpQGnjfGyKYsu^b+89@l336!4n?uR-Y4ymyDF}! z=Pl#8#T8f}st``d!Ta%^B);GEm@V|`5a>`AhXcEJlrb_f7YU+SuH`SD`f(t^W$u0` zENW07HntXK-v$DvQ2$LY{F%27*Sc}GCsOR_kZ&Ihrx8+1FeyU{ZYGJwbk}vj*0jYg z8OX;bu$iyl@dlhD0a|XB@3FvqIU6rC>*013*a_PC6Ow?2W=4&HG*UR7$E10Ghwyis z$X(r{ah~F%=cgoAPJ+65sIre#NIXlNevz9NJ9Vjk+h*YFg#MQQovwP^OD#~)5b=>aQttQ(k+|% z2PBFxf9fS$!OvpRr{~qG+^m>_gM%koISz~m@N;Ihdtfjkwj1Cu#Tk#(UAi%XdRvR*e!oV9b8M0 zX=KpLi#V(3foO@frDC7ChCtr8LMRZN6J?f_hh;`SpfBkdZ_XHO16qa+I=nuf`*sJ$ z$cTjE;4z`##+faP=wl&Vgbyz%7H{?_W319G(dpgE(;5FR;eVA3v6fKn!{2P!>xdeP z!<}n0>Dv}oXA^U7;&ymZ>-ul!prYcdjiedj(wu>kO`(9Dp+^2etpLYfB6d^)!@+-! zPtqRp+yx1Q@g0qXyo9&G7Ski6G&|L=*2riW51l-yS!BK}GZ2w(*Ek|OS zFA)+`uLx8wa)N`Y)(ExPWcBnQ7UAYKGjqPUuuO56hTCpRdq2)1Pjyb7Y7`%x$!4-L zJ0MtR)RFZa&D>12G(2|Pyftt|0b+#SUB!Sahfsir0Skdt3_SQhO-hfE&%?q`r|uO` z-DEP!-4wFyEaA^^z?C}f`!%S8=W(aMl0^*sAu{AJ`k*w#!x0k&f`k8vd-ju~GC?8I zl^YudpZSgvl`b}Ou~JnLSI~97O$zBuk_7ENDcf0sOW%Ic`A2fF$F-h3`2;!;&Jl23 zYxvp$7&IRGbGScg#kLL5DRABSm9ZLKXXhB!6`po~0Q79(HMJ=ZLgZn(q#j6PI6g^@ z^t-sYoxgEUAkenrbr}~~6crgcs=wqg;dTAoU>Wr8QDbLdV6Pco|98eC8cUy52cA?j zJ`H=pVi0YgFV=wCo;8E#UxR$56zSa$R7t?+c5({TGZg^?F&C4701?p{Ao^-$$1uBq z)wn#9>dw;@kj;ev9;Z_lyT$cn*cfP5bP{PmcsEf#y%8l;a=`XYN!D|2884sr@iXfS zA&6j0t@uj{nf{IczU=2l=iSFvJ|)q2{>aJHuOa@*hY?<$ZFq5bCn8a{hIuH| z6@&zQ;SwI)_)bObR{f#IfgIM$!Eo~4Z;PxP`*t=}FW}Z1Lp2s>7#|&7@37U~vl)Xy zoer-vDgP6!o+mMENuWzf#VnHW^Nn~gtbbVfe%)tKpZU1?S9K+9M_WoGrQ&v8O_r33Y>vx@iQ{9t_ zgWX!J)(!grsw~1z?WjR$W9&OG+oXXsWq|N`CV)hkO-!THc&_o5 znR(T&TTcr7I56)s8%ofAdEYJ5`+`C+OiYM@kWNu7F-4C}DZ*qX!h&xEUOT_jgAa7e z@(CoXaVuf-RXwZFSVV)xt4-(voO6_eUhB){1NZ8r{d8g9Lbac*4*6XfRO2+&O<893 zFARCG+33?qPDi(oC4`;WVy!s0c8Z({Dz#A3_+j_a_35I>N0Shp#M8QKVOWwEi4b0= z5P6^&UM6pFB8S_<^YU%xvL4S=J@gJbUldl7G&~Nc0tW-*$5n5LS=v&KA-DNPJCX;n zlw?ebbxAA)js5g~Vb_isQTrH>%at-337;25LLpm-HzQJ1g&Y-jIF&)&-oHlZ8IRBX z4Q%i^(&JPIU8d&0?FML}n+o&8<93+uTxi&%{%Vak0v#(~0`>3TKRBigG8r-rQ~49# zyg-NpTP%ZfgCRVRS{}Y^2!#T85e6wvm`#Xnt@zWllRpt#_Vl=Foq+n`dC#S51L&S< z0x|(Oy@nDt9{OCWK@t?aC~%Dh(%ZdU?z0|<{A`L(@y^o$H!%d zCi~~d>q}Fkc~mI{n!!`&c}7&4BklbsPr!Z8Ft$|+#rNR@S5+F(^S;s{XMFGv`>pAw9lMpQL1VFG;y6UV5th@C4CY6jbv`(<3LFjo+ey?}*RbtO{u&Qj5kI11&}xNHo13q; z-tm;A4U~cPfRT=nn#@F^im%?lncg;txNI8dWmQZ5LhiLO^KN{m{IL~VWScAHc7z;z zxlr|5Zrc9^c?d^8-Ipq7H}3eqg;DG@>pVxKtIgwV5@e-R@ScsTflMlk4TVb|dl+Il z1qE-IXcmTE0hQV@KD&VPr7IfEINW-H*S2M=&`5h>GxN^B%VG?FMssDklz zsfVZiVC4u?(~Iia>i1*=`G^)LPc79UV#LG2lg~YIIUQF2KDazjo26B05k#ZDz?t>u zDsAw1p7Ws1w|mSPv!ElOh7#a>7YLoyQDHz3^q3v@Rc3z<-)Sfk6W~$b;#m(O2h&mt zk#jE<3GuH5GAC>8)_!e-14(D)KO_AYe-v{Tmr?*T($0{A9^9E&yUwU?vrIlH&ZDgv zFS~q|1si(%7y=AuszfQylAE}F1BWD0`Ol}gN=DADoHkP`yEK^XOmW+6m*0}&!|4)H z7%JZrNBZKwZ>{xwTTU)>ak0fsUXVPn zH$**w99x{IxLBz^BAhW8&6^2p#N-@i&44APD}{&S1ZlfWWIv1?f)?^ohH#7(8j@0c zP?`9-^e6L*X`)CeR*J@{=f6j_-;9U#1xDs9SpPL>^wq-!6nxC!gNnm$M7vq=E3wl{ z6~pkueQMBgU;f5;M}{+)%~p677!NAq%wU`OZ=^I(hWtgZXK=Sue3OO%a&D59^E*|J z&a>DNq9f%*t92K=!4a=vY$Zgwa^h992k9#mM6E_erGqV{Gf_JX%O6e`wWyV|>U#i0 zL>x`5B7czW3DbP0xGWv$Z27d1g9^H~trxie#H?n$Iv85T?*1VHWXw&Ygk459bJB1k zH20xm&uYZWr6BrH$Y9d2j3qm6wATV$U6UyiK3C^%&BJNOj2>e}#eX9@e|8dT3j%CC z5=r0o{om0-1M5sfyo4pwaiFbISYPrk=npbcZK;6S;YnGkbLZLY#p6-ud0}%A6~A<& z8=v;07oNyn-wWyd)RvNw{VIj!bm51-?R+&KC#BEf&#dmRM%LIa_s22anX$akqL`Ri zv#E%!9n$jYruldcmdRE>j5`?U$W35Y_q$n2%E9JYB&yHtKi=BN^?TNReWvn>73qz; zI}ZY<$g2L}_XCffyEHRmwNkrI_2CD#va*Jz+E?-!;@$o5dY1g3fr(A7+bQ6q1eu!@ zs+L~JlB&rJqBA&1HWEb+tDOWJ6<$+FK)V!wv;c4&qJC^>Jf zqMQvbf#qGq7(7u;b=l?9xHbwfXhWS1_i*S_Iu(+wuq7iqFB71o?w#SOqs@|z+|R7h zjE`J0xvK|>^$rHfh{5aa_y$WRY1B5G${MeQ_dF11J*b?%`8J%Vw4eu4&=(W>QVu%~ z^B&$gR7ADKRU4hozal=-2)oagiN} zH^r2k0La^aw}+}m0S|x;tHqZ0mrYfIT5|@|)FyMh4T2u?WgM%?WzqJ2mpeu>(%mZrWK`u5@?`UfZ@YEKbR1+tka;@R)ieYf3!~k zd|ikFF8AwH@zd?$CUBhuNv9dp2Sf!O=2Rnk0jyEl@dA6_-u zSNTUU7yFW;1Scbr`T)a+IKfcL=FG-mZ3nNjI!; zj<@1suMn$%rOS+o+m0+*-7yK%JExOZZd#*UEgtxu2ozEOu5wK~k}4~=U7ch44v)3E zR!kO@6>v29{uC2=j>ABENY-*YpRe_6<@S9@1Af0FU1d9vIY}6$wAtZ!dS@X9P;Gs( z>yOgmqX1^K73W((VxxZAy2YfVf!UG;QVWu89$62uV)#<)?fvdt8w~t$dxwoMa6_xc zB(X?T*4!fzLh73R)iT@UmDuv1)!(9Zjk&C10AgRj&x;rrkOoe4GgX{2ui3XpOfu4) zS;>-cqHp^pUq}Qnw`aldT0*6)YAB{aQ3ieZPXffCeKwba*zca6&z5=rAzv;x`Ck7D z_rvsx6Thu`)4SO(C?C%)G;#~T;m()GB#TL*ib^qVt?E&Xlh2LxNzB&G3pHdfta2={ ziPFm*GdM6nrkfrd45?kIqV#&%fyFPE#XzvZRZLK z3QC9d!M^bqFe((O@G!?$ntC+511}Z{GUTh2bX>4vL*SGE1UiOJWW}*Ra2^HK5yLs( z=I%Y%UR<)6LIe##q>(+ssK;nyIjcZnOTS!UBoR%_YIcr|+x#!)3v-bBe;v;ns!47l zofwXI>wK-4?f({2+Wh`v`F6%np1mD+q+!s;V1hfOl!vxQ@r8;d5P+}dIJIm|1FjHN z=AQL8ZMVef(>(6~?jTMwtQgIzMSt#E95*@zskjDLWEPVC8ZJN~6bFZ>#&109Qo?@B z$!e_hxJ5W}#V6p(44=4@tK4Vr`_|Z=d5IqU!f;7A9NWa#>A|>ak-H0-rBwc1t~lm`8&;^P(3chHh!fd7wjDDEIB?Ii<_Dy7j5ZgQ>fy6 z`b`%0{!RoyDK695<8GY2s_x6Gg}^~s62>aQJzu*3UQKi`dY8Myi>U&k07q7_KTXlG zjU>@Hvop`kF_25_oO)mW;MR6&1PG*|2w(Q@+E_!NL!-4Bpc3y(YI{S|g`4Nd06`ZQ z2A$?JwSY_!Q401wJ9%_n0#f!hK zxevC?>;|5xvG(K@8KTm@N-F5~P3a5?d|eZtIQ^Uy&F}nU)EUM%;|7B|?_F@FxQYEn z=r5jraef86;{$Kt4#$qI#vYf7uy8jrZ|uD{R!62d05Q|PI9gSW;bD%TIwlw+wpv_sWyU*Y_W*DyMHOy`i$QQ)PH4;h96y7I*W)O?*!5W$&@D6xoAo^;R$|i>nt`2gtb|#>Tw;_XSQG?&)(S5LL zx=A{r+^)=VqT-A|(8ZsLB@M_t(@imtQDbm9FlDlEJZ^3l751e8Q(evk4o(mVf`o(t z9<$Zy)Bml0M_kB${Wti2Rt`vHfPIRi&S!tSLD9=>uj;T}%u1o?a%{t}p`x)et8oS;@=y^G(sg2LH!8%W#oVeTbg_^11tk_4Izb#uQ(iqVkf8N~gns-FM&$$5yIb#(~BD z3^TR%@ zwM8b1+TXwP;pG9pJA>Kq7TEL?A_h=I?y8^=rorfS%>VBFNHCWz2-n5p za5D@C#o3SKdBd4k(7@k5-H~?rdVMx-+&NJHvbkGFYYP84PT4n9$9-a?n7CDO&`P9k zP@|UDZpCciyKpj}jV>nX7#@5Bc4Gx|wqxz7r7&T>L|(~Q1P1*FuVt-9uPU8!S2_{* z#-~Ql!%DY?MjCgUjjRPuoC?#|>*(>p>uS)WSLd7Mw?_1d8XR9epn+KlY8gZtsdsVSv5glC(NkB{sLOYMLFfAkBXZl#V1&nerMzppPZgDU|6-`@QL z@`SpD;5>7bJL?h|Cok<1A6j1jeu4Qy2?Gs1^yT?qm1I6mib;dc-cG&W?q8dN_nuGH z^W~d8EEFQ4`*Y*hPdbl>RV4m{biT+v$g8ce{cl1M$T&z^ayrmN-q+)}?@^r|3gQSb ze5=3g9!{R&Fv`;y_1`3X_piE!*3pN@L7Y!t59T%n?-cH0frx`CI_csUjW4 z)4efUnlmON=Zfqpu`k@r_E{2;CMj_mnr6eE&OacS`AO;0%)|A5S*P>(jP7T~VvSA0 zAtDMm9PZ?^S%^+YaxYbPf5qQIG8=qLmWR)~i^X-c&7b{itZRt~)NB z9Ml#qkm=YlIsRI$=(sc`;<+Jx%!6B>UsY=%L>yIX(I;Errt-Du*i$sCMC(=Spjq8e z=SS1)g3SF_*evSO84PdrfZhnlfB%-5Nn)8u(L^1tzQY!+!4`RkC#h?X{+(m`1~f@T zB3&i;dR$iR)fu&^f_*284684uL1Y=K%CqtPH*2nYupjGl+(D#flv%M0r?G%Z2CI>5 zp?a~=akL3kTPDl%|-XwTq`3_oGW>b5Q3)C)Vu~!`0;5k5T6N)r`52dii zv&d0YQ*ua&nB+bhH>dKs?Z*u$gMtse_^gf!x5jau4Dh$F)NP`IW4Ba#Z_sOz`++7F zCD2u5HPkxz_wO)OXo3|*#D z(2}yy^kU)Qpc+@I=!mI^^j-ir;(@QUT7Ok(N=>Q1>R)I8Ip%2xnc>9c-T-dFusThRj(u?&+)wK zUws&O_v-vu>%X`D^&uKu3Y5iz!Ye5#C?w9-f9~Z!60Sx7I>AnZ!kjsE+@c*vKlAqIAlpP>^u5$CL>ioPDjj065 zIe5}Hh4!XEYV#kbKTfqO60mW&%KxB>PbV?o%=9^}TLlzD9=CsWA(it5x}s6{#|$sq z#Gl{+8P9TbaW4m_jul%tn+`JfpGNz#kHBskp3*{e0?g9quh$C@5O1;~B1v;wTT?(} z+11PPMJt7FH>v)94)%!ekZsF){i*|ZwU&N$LBYF`ii&gbgrytQmnfdFP=5?S!|_{N zL^f;<|05jE=68>qp=pH`no+w%z-ACN5UiG&NeguNFfm1=YigvyCvnJvSNy>&)i3*V z-+cPdFAh;sFiZ$q8x_T(!JP}Rx#+;&m4{;^YOMkp+7q#9^u6zna)m#SD4)E0`l_ah z`)|UKN)0=dGy|V6Ke;d{G{i6wwxuEvs$mY#?MSxlbZ3f}@04r#)}1h%P2_CuksLvI2g)A4!k{R13I{SqCc z033M{&g{IV!m(UbQQEi@UXW0XZH`gJ>b3Vu8t=pH`062b+cha0ra{-a&Q4+=yFAz~ zb%4u60_kmD?1Soh3XU~qb#Y!Vh-8R>MUJG^_SxGq&T5S9uscJjZ{(j$8K9@F;ojEjS^4;TH1j&U7 z2nh3>?@+!H%;WROoo}?5GoW_{CW;VKzzyB~kVWZ7kT5vd(@s7f2A~mO{E>s%NyP4k>F-QRP}PkazHMv+EUr#-TJJFRMblrP()y zl|cJL$}2*4gn+UUJkFOtHm}jdy#RjyVIep;n7s`tVRRJJW~tV!pSXMNwWxe%x*4n8 z<@fD2ID)!H@0L^)BB6U=Z!kE!T$(Q%j2U9I%T<~v9D#uM^i|VTw~x)*;)Oagp@2cA z`U@ML&S<0b^V?u&F=+{nr=PfQv?tqgk$u<3^_FO70*$F@(G5_f`RQ;nTh{Nu<#q#`^@Rt;UJ{&n_0C`%&{<4Bc|LREt!cAZYMQuj}#%s4un`{LP=8@)=)^fERydE6aNI zRR&qM*MF5HO6bkSM=|dd>GZ?v<@^mO_LEX!MPhIjY1U_80|dC1g*-p7^nEmts~O}? zlG(5E%~xZ{2B%sb+^n9ji-_lcu9`5a+hLOPfn3SnagOd1p4TL-@u1ZR?W&9umPoJ%&OSTXZf{EeQ5y zqd)4mf`0*#xOC%ySe_C!0%<*YB~4-dbSQ8|(feq0U5GX1!WT-9IqXW@4~7DU;Fs{+ zENu~jT8IDE%uD`y)|nF!rL6Od;Sx0~ z^3N*)VBzt)RX;3A5XieIqD3&ekpNV<)Z{NRwOtAEzM#c*uGcY2dKc4 zcKxdf5;@Zb-8Tv_miq!(jaWT`cCV-VQ!Y`Fv|jG71E>yj`iF2&d!zX#+09k6r!>U~ z0cs2{ekNe=0iA?HmTSo|r8~bhTy0Dd+~utAoBtM0?j@GKa}?N8{XVr6EX7`3u+fN)xG^288umV=9*=Pp$)s z@W>&kOd}oGeg4*1JG&7393)b+pDsTtQjoCAt(lptE-NBrLUp)Eoe~LtdH-iQU1sOw z>&s-}Z0$wl9S%iY*s)VzFUIMx%zJS4o6JbK4`u@gN9*d3hzPde=lU$hIHuU)bGhU- zW$^q|YMCQ3IDcnh=ua5F6`{?8Rzcs_am%IZ3Ba9WLqCvWAG~1`;FzVML>f*mK`QoWutbh935u%ZJn#W3(&~s_jhI0*ir+r3Z zuy$S#%@$b3(UXTi%UyP|gc()q97`S@HdYS)M^^EtFX55#FJ zf;NxqOZnMuq+<0SH`{G)1MFBC*Fy&J6p$jb^<=+zkGKg{%*i>_pQ_5qg}*`i$iO1V}BtI+%1 ztxgcJAVp;SAL?}Sh}xkwcU}oH_OV+1z~`vzuC%HtgijB3X*^Ddtq7PnWC&*bYGvS! zcIAHK^|DW`Ox?*#U;RK1A#n#`zm$p3?c~f-B#|(0ijBi;<{3@mvU8fN}M5ed3cTiih?Zxs!*d93C4R1Jr>@+ROt>$R!4@W~K;MG50ON%N~i{Xb3^HfkEF-o?W=y9+gb9m|5wXnLy)MnuXr$!+QPZd^1 z`yLu-ObqNsTEDhi?;q}urew#U@n&JKpOqA;0Rpbn$J^|N8S`J%hA9Ca^lr&H%QV&k zhljJ}_xco=hhL09!EzAQW@^qV%;RgG1f7)Ka+~Me@Yf%|svvL%)C<&P&;NpaoE|?@;pSMG?dFAcbUz*>aJ=eys&i^}ya8LzyXBS`(mhCy`t>Fc7uz-Jz%z)5pDA?S z1;C(r3yX~RuJ2f#VmP1*37}D;u*z}jOb#cE20!_<*7jAP)HhPmw&IxeSgL2Tg`CW57bFtrj!9t33m3oc&Sk9+JQp}h7wlykGIc!I9TUIZ`LX5 zqLvZsoUh0z5lUG12gSe1HdA?h-&B}vG7lxeR8|B(eO_h1aoK13T&(po%d4ibYG7jC zs?E+7KhF6|Bk~_Nf7F(b{2dLyKIOoH3Iu=ca)u2Key_Ax6_(L4{L?v;+k5+(-0`r2 z=I>vY^1ANQdpDKbjv$Z%g=+b*-YQh(xHmN2$br0w20h%PR6{=>nw2s;mn7|9wmIPN zP#*eG9Xf%6Q4S>DEEHj`dxDD88p7*DL!F&6I6K-~)zYXe`0Xi_{+W37^_{_=&$hl^ z;z%p1khfIu^d8249gQ1DuSVy93rwVc-n$=h}yB4O@?rKTuoi+4Kc@)~5>n9Grrc;`18q`aAv-w`qiL-7!uog{Zl9-3{ z)x$yH4<-P;kspoNKF8bJGWpM$OVZ9CWpW>qN(X#D&MLhg&O(<_!t(3sb4cvfi4D<_>`{;2m|j@WscC~Y3$|+SK#`+z$W)<1 zk#Pcf6jXf@JYAh}$7!x1?yt;T;cXeXK%#IYLoMSHD|Z+v$&SS{;A-Eq7*YPNcUuGN zD;qI|gOGOcOY~3**8IT8@F@R#GC5E$Ta(fL`mn-kxAH{s>>179enMRMaZ(l)=395V z>jaBRh=56fSY0gw1Lx5w%p}UIQ+lMO&FHB3$i90H*;`RqB968dg1`c#f)7_4U)`2# zL&fYkGa6-d1|-stspLw8S>HKgyMyD>A67dy1=T=b6yUxXZMuyB~$lUMmTT^r%3En9DT2H zzb$kE!J)Vf=hy#=WfKrc2h#K4LVHBN0-|lb-_@3K6%5ptPBZBrpR1YE#j4lNPrtF^ zB)UARoKDAK`}&oLQn0Ll60Y2t^c|JnErd#Gs2Rb=;5Cya z)~ft-dTkaMFQZrl|^G`_Ftl!I>XO9JG-Vxv zKW6s(CqA*K??d5%{5D@4QLi0gx+JRYLfMpyZx2LS}gV*lg1JTZGLZyxg%vFfp^#TXSm$k4T-S=-F)kJ zX{ld?If2zjDSQZeiY{|nBZd=BJ!piGNl?)vNlfmZU9#_+cpS9O&;$Ci0_&$_!CWdb zVpt3w=liHXeA#cNa>;VV#YjU}f}ma0pp;wY)F$YB_xh+jv3a1K+g*YbzvI5DI9yS1ntARG19L5IrhXXoIi; zvHqZJu1+dOBSCT;_U&Ocu;4oyBu^3(w`G6@m`dPG>FD@0uUtuX zQe$xd{p)kMKNNT>^$WjsW|PQZbV#S|vCsg*|ILYYE9_Fx^Y}dGQjXhmM-ClaHe9tn zoOgu)Mc2otY5HQ|CH`a4<3phlLfLB^zWpa;$Zh~5)>k2+jMGqTzE6kY`2MfHz6_n- z9jecd$oiod-8HZUdaXZ#5e8%58&~#Kz99tJqC>%dJ;+p;xSd0vEUce05;H=8Lmb*y z>#l&g;HZpd&g%lxD6p0=+b!hUWYDU8T6;@Pe<-$^t5Y65e0;x*je&yi&RooR=HzZI z9IRlHc=`-_R5s(QG)tRJpm2_IaM5J)`{y#NhLUa`wYLL5>srhDiRx!id& zwc@}<{;(R3-Ya#nQva@`rj}Uba>e>A68mTZoHO@2IQ7}U|6SvLt$FsA1uGrmoQeVm zOj+rx4s2(WhCaliwu0>i?x&%7KX?Iuj~b{eFQGWEB~Bu_X5G91F#5D{S=i5Im(J5p zh*SF)77ECe0^C!phtrMC2K30_yBGXarI;c*PY)Xx3oJR=63A3|6LBbTbmWl`csNk0 z=C;hEz20Ufx96R;gUo8aB-iby0u4ix<8~yA4wu6>=bgS1!!4ICoST;A2%=z>Fm|pB zWHuxItWe0U*LBsi=s6H~sxUW-t_L160>WQFYkC6gsK3|%Oy5OCQTDUdZ5LHL!aAgA zD5cGuN9n+^>fvEC#y&0x#Gqsgof=1z8(=P<&m9bpUB>wBSDbqB_(Xu3mo{&KdK7fM z)%wXr*HGXk|D*0M=5xdZNB(S~-@C=>`~Bf&jE7Pk(-3e8ZtpfwJx`*r=!ppb znG6n@41OSjbZIduBCPQ+nX!l=f9}_%spL}mn#zFkh=g(c>BktrAiu|li;0QVC6DX8 zHIaHPV;C3!MBMBR>G+9P;8i&*NaoVc@XYn-%J`Y6^Yi*WL|nrys-F@~rn*%0SP2PQ zqYG(fck-g5aDqqv@|%D^k^7^vdD3Vt+!{I5lQbTCF?Orza8DzxMrHMwj>YTtu2TDq zYEo<#_cM!xs8h6r930u#z~e>geEWg#wo~j$SS&E`qfR!3aWI)lf2seZAY#0DipZ-|BiACIJ3lY)uRv?3 z*U8pzG6(8Nb*=dF?~pQT*+mhU^AW((#kqM2!fn+|H8^?9tfDs?6$&T4keMiW-F~)I zqwGtk*ZhcLsahFMC>rms#6KY)Qc#6<(cQC4%8$N5zS5CeXnw*xV%FKE-Fj^#N@c;a zCtXOzlA0&a7ZRgmNy7>@6hBb^7c#^drIHy6hr82NIsnaZsJ>XPHJdS$9OM1dTQ$JX zfZKn$PyVuvZSSlZQ4lUd6U2@lQ0#bOvdmoAGDF<4%}CtPDT4QHj8!>q%N^k-_y7i7 zY4FSQ^9;~+=|_N$&T@R1)5&)fQ(`gM<%Q|>FoX`CbqauOtpT;!zLIQu8KRKoaAnV4 z9*YM%w<|D@&ZYM-t*)Zwo}3HA%UL^N;vspC2(k+{%$A5KNg=60IOOx5KJY#%2`NGMsfxXNXxcWw9hn!zP~KX-&?Wj~hH_TnvA_MqqMVFdNeLcvx1S|9mp}>4mSuzhSlr-9-n0-wEtjKH7>nDHP z%fWbSmM}=Pl#$vsyCL-(Y?IV|2DS^hpHoO2#5c#1cnW*hORO5Jv8L@RXc!o;>C4zJ zMwFlLKnt1(%j%C95C7W+7O7Z##CUSRHy`PtOE9C;4ROE$e)r@>Ytj{cJA6cX%4UYt z2Lb8@rYVD?x$Ukzid#v|iiO}bjI>7c&+}K`C~BE~nu5GM%UXJ^ zrrY7@jC#LF1^9uTP^>KoT8$&#KGi^?o_%NL0{wbxv%yt|&UgEgnSG1b=j}2rY`qUF z5>{5$t=mceRR8;t`;QPqNo1Hd#skoi?S+bCH(50MP`(OAnC8wf)nzmuhw(ik(?cYq za!=Wu07o!5b)&1(T{$qoBge+HTY#s)hg{OvCQ(O1>?Z+TMz-CB?0C_xnu_=IHs?22 z1eq4_AW#a-G@&#`pU7{>PcEaLknaAdFoGLA__UN7;7bZD(Z+$a2cL~zhmR8r$&pn+ zq;xz|v08*@=9rg~f@TmAh~Om)(b2~TXj z0ZIknd9_(6zj~m>VIocE(;F4tmJR=d;eKsJ>^ChZ^V#K=6PFgoH?DP6JBzon%iXqd3D(AHVJ?k}{YJN?TH+U(bPn`}H4z%82)6g*g}QL1`- z_Z)Tmg0~XB-{8*7XF5Aq_!MNGIJ*XEZ1xI8Jj>A#PPbCnuai3F^0k~8+KJh zeSk=x)aFWMq0lO1OzDZk@A5!=eh3v6(FW;QW4rU%4t9H;%6dm6lM08y^fCCXJbniX74eAdG{%aN43 znZ-zzOdhCB4*&f8N{RH1*L41s}bmi;u#wqN#4O0(8vlJH1o`Eb8`1@GV$EVt*l`nIeP9cSJ047GBdRg^il_^4$K*)5q&&!EZ9E2Y@KCgI^>Dj}E2G z**HqTsTo4drI&1`j~<7%MFO_rUbtl@P2Qj5u_Jk@FOcV5AFHWasF2pWA@Udv)um!k zV|!f=Xw3gEfuzQp)=eNad?`Y>lY4T*+{KRs&w#FHSc()-;g}mL<(B(X2mG33`T=<` zO#7SI<%J&Ayn`;$)TR08;_Vo*g;*JIkARZ|vBpL`m#;kE79BR}S_674;=mVK`P~$q zVX?+MXj~78iM1D{(8_M35GWX|@0kML3$S8gC?5S8>B;v-5fKsS`6-gMnBmyzuLm+e22m`(MqBFI82(a#hnE1T zMW3FDb3Ow>pF2xp{BCe+Sd#*>&HFm$Y}kogAa^pQl@s5znSe^RrUR`WSBifO-l+T=jrXf2t%fjKi5;c`$FtSt=sF>Y|#Ykg*){c9FDAnkuC(UEJ)$- zZ_#DO^9Ec>w`QtbX+J83qY9KHo;;me@t=hflfj$=rf|H|%S(T9Ux%ShcTY?&-oel2 z!G4HgOpx`otx(jnDj;}{9FuEIUd zL7=qZ(QE#L44pR1ItAWd5+~jEQ<{HhQtmDIedhmIlSR91+SvbWc2-SftTPb*>5|sR z-|!ar4m{vK!kfWCvnc;*wRa`|s%)SZ1HLj>nf-#!19w8A@(7?yGoFnxosFKo1P4-Q z@2VN{>fZOqHC{_`DMe}}D*gL|1F-hYbtfXMy;pd;2^F)_x%_`k57u-BQQ@kXxfeW1EH2$_BE*WZP$8sqW zQ#P-`eq-ZwEbZS9kjeeBp^!|ei8b&wGR?nbpcH+(t#Psnguluj5 z-Th?EO68wD+3O{ z?HQkl$Py59xj#-YM@*clmOxC70Y)9EO_M=IjJ}|7j1=QJa|Kp*{<{}Bj#ED+Q(|u4 z7pcOFcHG|@;14rnw6?eglC7SQPB!B{$<;NS30?Dr3_;|vF?;W_r}F)_fx zN8S2Z)P89VHLnaJjmSg#Zh*v5uE$74?M8r$Qa^?)kzGEOMmHP{NW1x*6C(o;^2MSR z>eA;+&0=?R!@Gr^40mT~IzSr4+x#=|3Ohg$C>OU6&6YTj!Bf_5swx;)c zQbr`7ei2REVDtGWQ3RMx4^P9-dzXVTpYc<_$!wlPb-K8j2AjnOC!o~u<4Uzi;dQh& z)DsPyYvLUFkwLJam3cWfwGGjO7Ya&1wgagg{omL!^l>K+JRHIdm|y_5r*Z&RFbp(o z$$D$&)8Y+g6s&P{`~BQ(DjM)P_^Cv^(QLD*k%U6@VNpiEI$|T2)=T+@_9voMBHzK! z{+GvO#Sv2DTQx~h_?H-8)F{PzZJjWy=y33VWdZ%tm4~S#M#Xis&My z#M@F&Nk$BS|FOBx4!FM>wEpNNHAF9?H^btF!kI7arszUKJ6JULbz&1;%I<&q%ReWZ zuf+u+FK{=gE|#7v-JuZ}pJEn4`WIak#Ot^f;QRac@2>zI=P3ibI>WgMcrec2L7R1$ zjY`#5>2`1rZmVVYZw=-J4Yi4!xmpeU@sM2j(+W#6zY`islJ}isX9l38K2CRuB)l|Q zg9HT0D!eb{X#&H;JNi4`A2Kv{c6Pi?=!aVhY|2~t8$Om+zzFid4N-J&FnfBd>MrN} z`k$_6H?HF+J?!0-WTF^SJ53K3>{tX8vbgsr%aS1c!OXW#_r++GgB=; zg2pxBCmfGDBwQ6*AZ>CeVpub;yY1)e4MkL#3EjiM+&#@c=*Zrt%xZRl;`42HQh+fu zkus!f8Wd-S+p5bbvS7lStrf|$d=d=QJ4t!!*_O|jsNOC@N9DBujSfdD3J;7`ZFH=x zDjzSWIaK<O4Kj9{mPp$c4uw|? zywaJ{0`Aqf9~qdBr;CSABS-y^XriCD+9x#VU$ZmJC>?lcX=Q-^zo?ZWg-q>3N7{LC zvtbt==&j11!@cFVuUw`H{+j9Q-e}){F5akNLL)d@nvEvDoxWFG-UEY0e50fjOc)O- zDeG2M=ff|4-G*f<8oUgZ5}ld+G*6LSmumbyAvTH1lzXTRdl|S|O>(ss*I_<% zJ+kD76AnlDG**4TU?hby*G-g*$K`(pS*>QH4-?pox_6<^QrqDa;SfzbJPs|}^bo!| znSACW^bSZ3%|D@#oLu|BT$J7rY8l?%&gv{CFT4$+kNB^bExw7!WGoxQ91}L4HL)0 zCYYZr`$*cp?wD}I$zRA_5llrZULpv6Ix+;aW3|kEvi>WjyumT_h^Kw|F9JUGt6+lG z!Oqu>*Wk&VR{UbHN+cIj`d{t=VkraD=hgKE=Mp!%u(PYI)UN82xM0qIrkbC-QHPZ( zwTZ#OJ7g9s^**$lEW5=sLCx9!)(oKg*6bw54)<8_BE!SJ-tr(GuBiLCaf za5R!3299y&u(btk{Y-IuvaG#hX%hb8a*C5P12wm1{3f5!8?M$LzDAyx8|(V0sE>dJ z$+XgJPoMgz*5`Srxh!w-uGO8X8zM|je=HQ2=BzI5?_5F?JL?S$>gK2%-4R{fH;Ug| z6rojMbg(71LBgOY`e^|6c|j!Pw^uw8q}_u!#$Vi-U`9Wor6& z(tY#%)ahewc{vfGpIJfl-?x|XA%Vq=ac_hyy4$_;K3@}X8?>g)ZPcqwXCfwmbQxxF zzH#60rdlYkxl55VJQO{;)P8+^Edh`MaO=s;H6DR3j;HQ7sBAFqVli$wO-onB_~WNd z9$Pg7CbSNjBw`4;wbGNIK4VN28WIwbyM9Kdx264lMz(Zzt0yTxHp!a;(JH-)9~ECM znCM-3lU_oUw9ug7hbjYd2*8}S2<6fxrTd|tNLeW?$pPSDOzW)Dk(!Hy#Q2-j%^V8> z^MJnKBkYjJ*;37eqPzd<@!In(A~V*XBLqmy*7Xl!1J`7!aXDn>&DQtL86-ZF4Dr&R}^LS@5;dE*97IW{<{y z%P&P7p0*Ai2T`spgC*R4FYpRZD95lQ5eu45Zx%Hg5KViGLceElR(v$x)l*HC=VtyPtQ+g;rlOCkY2is# zX&+*M48xjl>nI~jz;d7{+-(wQtlWiEfseLziO=CYmrl}4+5QDD@^YU-!n)9b=f{~! zcdSrkTE~y|m6XX$0f)wAA2?=gQD+E6Ik5b7Ie~*Uw_(k8(@0I`a#vj#vTx=^=#Jo4 zYv2IfN4Qn8&BOe-wqTte>n*IgW5lJMW%d~mUfFcbs1u* z8RFwsnio$YtADv%wrZo7(Oz9CnvbAgaFJ_2R@=M&x&=+?h}`vBVr7NBo^s zXikjA(j_!YmCjYKpRIQ!Rl{~tl=Lug5CSosHa!^Y>M_6F`~IcC<`J8U-C9k^;BVbN zV(5g8@@0t|@D=i8EPBB&=(_1H4!etiA%n{iL*c9f0A+6F-g)tB=dq6i8DpE7Z%HjC ziI{YIM}9-_BtZI#WKl6kU;)wOEg|e7Ww3VnH(7o~t9zB5=k36~L}XqPI+PZ~24dle z+nwW!ApNE0ku4{M@2~BR9ay969LA$?>&mSd2qxx5O-}ec>wbiNCbx?@60d`^l5cG+ z_jlue#?~ZuEw?q2=~Lgf!-yVuf1H`ZK}MNm?FodsN6?L%rZ2s29z3I&vL@&`lgl>F z#r&%kyfzeqbtV&#|MG-t`8zK(lYr0d$$ltqC@qOL^l~z~jj+h#CREg6wZWy?!0&l8 zFT_I%Q99n2J*G9~hT8NU{T2w8eeaYt<~Ks84an@G(MU8*`;tPQpP^Byk9Tou^M2%% zK9_w)7l&rI-f|l%n^HNcqN~j_z?Gfkqy7=AKC zWfcrigQjgT;`dWjNoHxKrFlU_AUB?VQi;elTtOdA*TswX zy!L44Ws39HRiM^WLFq2?UKz-91qwnZl>dSq2VsznheCV3&Q1!Z&6aEJ-aiYtA=BTcrYfG~@VIyh zkTgM1Ao0Qh+%^Yr7w^5}`@|U81R@eo+boDnJ`818s5X6Buq0F_?_I$BBsCeG-iBv_ zpR#`W&@f1WhK;S^vE74Nb+J~%wlkLI;+HO2V*4BJ&nEZ_N=cjJ)lL}G`g=BMMz@c! zQFy*siV-7t8hQck7{l9Mg2dM%g{+2$G>a1^dQjKDYjh4m_MaS14w3zef^>a~Ps@>$0|C!3=ToL}pr~fEpCQ^Q*Bnj_}Qq?e)VO z5ySHU)aLrhLd9u@*w6MlW^(dA9Yq11f}Ay+xXHGMl^!SHdCz`zG`$Ngu1uP!f{vgj z5*oHQGku1WOdCdqD|nWBVb-M8r3b-4U#=Kaj3#J`(N{~K$62AHN62ISv?LjWcQzKK zKgi#Dt5&W__SO>`QrJ}i!$)&V7sry?~c8xk;DWi!R$(@Iq_DM*VfZYLmA+WJQ z*ywoASLQSM5yS6%_8nZS7D8B)7!2PcfTwcok_&R|@YuBNGET5m3Iz(iS_GkNO8u`R zbsDeP$Rs4$YhyPKmB;tY<^|hqwIj4spPS(;lEun(U11tpV#9&YQQiE^PcGb}-!}1+ z_1LX+u8$a)7gArL(a{kY-AidB$()|9x=%I4nS7y5@N;bRCJL~7`DwN5OhcEJLfj(r z78*6nv5Fu|FSRr~csh~X{{q_y*Hok>a7g1w{2;@owe4!;GiUWEIfT$q{nO?A!T1F` zGEVjzU@%h~JMPFCn28&9F&|$7x^)5oFWGYqEf(Gv9Hy|}DINwk2dYQ5AWDr7F-OAf zX_hNWw7GmKuE7%yo7ShN@SH;qIsrx6yFf^(FuUd2XGr;cWN!W0Z>w_pVL!Q5n8bZ) z16m_NQ6NxAc)m%|VWaKp#|C@!b%pCClINCW^;o0rbM1Yfy2ay?Y&q*jEfY zHd|Ag!oQdvuASMkHw*NCq2lT4z2!+X*ZsHlYE{?YeUqRP#;<~ChgRo|10c}n(c27yVO`ZqxQK=wKvf0VjhYId1Rn>~a8ckYv} zQY}Vo{A9ijTsWfKJOu*>>(_mGyiBS7h&eiB84&W2`PDpapRIZ{Jv|LgDVN$DtdbiV z5I8aKhm@Ot%|J13SkWM6RiM>wvr~Yce&Up*nzYCcAyCu4zed= z;M2bWVLo8qWGkAKB8xob$waUgB}74^>Zc8gNJaP}*|9joH%TxE(jW{MxZ*$1q8Aw}OnPCh)dcGYQwNNIYWmNm--= zHpw~J4yWoCnoqiSho(vm_UdMs%Mfw#3^S`pV zS!R!2>*v{xzlxh@Ql+fQO4p~fmOZlg^JyHt?MPD%L+dU11GE#mYj~RP7$Xc|XDzNJ zDqIF0xwr9v6*iI)RGPTUf*owt>i_QL^SeZ~kbbkpY!xwGQk;w{+an!OBx#d1$2 zD1hWA3ODjH=@dW_@m=&6t~w|r2#|9fE6@KOp|~BJ$Y4mKiVJ%JbE1HtKnR?!rjopo!Y*v+flRq`S44vMMu{TlE`lorp8}Y~&)gTH7>Oe|_LHr8DZs)sE^Trc;u#<+0m)*ETvj+e4| z^;v!RrUd+$5y220UAfO12%6!iahlu|Z?w0H;flI|?1ws%LT%J5wa&--M{hB(t#Ylz zVd^1T*1r3)G!5d85Rb>J@sv!l-BixW^2c}-@mUxn6r3zCS<2{vWo`Ll-a&Gkei^Fm zL!hKEG9+%8P|1gfM?q=XXjGg$V%a^A#AeL=tYxlscoAx%xY9<(VXk{@U|^|IMN0kq zmn_8-lUK;m0 zjq@p>V^E)AmCl9iBb)T~;vB;rD<=xqsznkiwE}vH1lWF@{nx35{6Skqdsk~#me}lh zJFyn>)b{{KLN@@Bm=L1tQaCj`NS%sKkujeub6-jR$HMsppE!Z!0-jvvib@f37gqa8 zuK_1Ao?@zK(1Crx+^YBj2+~0wB2>gN9giaxkYG^tzzJ9!qMyIy}N@7T>3VqLuV=aMhJLlKG|8bF%#&?^M>s<;~(m~eNJ>-=10AHJ4+&_SWTiXE>;|%j0)yWj zYQPep1@XOL)fQSpvq!Uhk4_T~Syg}VA1IRe;_x$^i9w;$MMi0wB2v4Sm*O>Omq%Yu zOf3)Krg+vOd9VK?e3XL+p}JQ~$QKV_@Xo0*>9;-A6RwkSM&kO@ewc<@HSPr#xu50-0fDU6sjE|S<;}b{I$yWHt=cYV`f5r-DSqZjd$;k z+aVep7ws% zZ`HHMbL=rn#iIMpC0H_G29$=uxiO7!2T{$lyq@2`2OBdg6~q+t^M{laB9K4_e}P5) zrORRaH0KFelWmv3Xp$l4Sl`Tjbbf9ygsZ?wXewdK$B_?nKbdbv@h`-;idu|jif;Y! zj4FiB@p%dC9MsQ1L_Xvh$NHjaYvB`=PZmm!y#;+kd*`Z1Czi`bovQEDH#y0bt6KEK zhEf4vph`3p;c;d?HNpYP%nu+PXb_-iXvojn3cH_HHn~|F9wfu0)E`%3WEbu|H42&{ zbt4|KDvN5*(t+T`JeBC90uB9eb705os@S3ik`DVWv zaV`ai1^p*gM){UcVxg=ArbjL-5QF53GuZf_CcRAS4~ES&4x5{~NKCp7c2yy^73>Rh z|BK*8c7n}yN? zJYL(O&uoWv)|0#E*|3F5Uz;iSO+TTXor2Naa2aFPs63Aq{szlvqfqE`x(Tig*bQiKbOAaZF*1)z1k;>TZVMkwWpE@7d&i`I-^bHyM# zV@u1a6daTD42KT*=lv9+fw7!MM+6cj+0h)wclANv~#OPxxDYrT8z7cG!~4`DrS6eY?{BSs0{1 zl_7=wh&h)#*!=5a1M&A>zgIMjR6TAt=W!_XGylt^C@^1lmBvP8 zF|dEZX88YJfDQR0$iFOqkU=0!Q>}qNO9`v2pSngP_^Rq&YG2I$T45~Ps_(hB#LbgV z_k%Yi*w_uy5Zq+plsC z5V;&nm+F4uP5Dzdms-<`Bw9wEi0_Xix6U-X6j2CxAunsLpZX~-ItS*7TTEouWb%79 zJah*Sn)eNC*f2`h!i5d);lY%kcAD$3S<$(ofjQ!Ly*RaeIUa~{_*gA+csx1&GG^ke z3Jkb_4aN7zW2HbVDCq@WX`N)A&ww}3U z|1TGhqs(e|AVzSeL~IrMyExyB3JVEs6l^B~~=VH`j$t)`lL#nBTGbm%ku zsMEX5%baF8CQ!jxud*mh2KfW*=#3rhN6#CBM&(`T1OZ=#m)S*~NnGs=zEev*b` z0;PP2|L41gD{s~eKyBa8KndG_2+e2!>XDWl)?0A@QnSk`XS!ACHZNe2lsq!Z>n$9=Q1O=OcX~9$&YbdI{&V)pwRB`5bT(MQ_4(2&J;e0$>5h<8tb+2tVM=6 zkUI2zXWj?^mdx+~u&zq#EPR>e?4UxY1FlBw7d2eacXr7}>y~zrB0~9|$D#wjFjoQL zF0mGYxJl~LAXDis3K@*fg?}1VZfq6`(GyV@?n+BQItNWAg^>YY&}Yf{WDfasy_M@c zg)s{;GRj5say{zNt$7E+#N;dDTC01blBlQ;90_|I8iOX9xIw@Ony3$^1iUaDwqx@g zaY$S&wG6~xZW(_2Y#6ZN^4F7K-HS|*2$h(J(@j~OcI$rpW!j1HAwS~>Z}~>cf3(ci zExc}L%~!Vq2GL)kem_Ty@t)VdR4l*y#OLMXQS)V|N28ajSL_@Exy}1;{2o`6%kK(` z4U2$$CFWY~)uvTR5wtcP#7Qm1M7iw6wJV#dQ$+vRGT(YLW9&KifBBINA2W=S)%P9mJR?o1%EJ5rcfl_?Q^#^Y4(svkLPUhWY%$ zT>^bRd;ySgsYJnJH{4pP$rtq2?Pvj&sdxJmadSRDoVWH;OLWAW8jYzle>MCU_8#Sr z=;NThG71p7j{fyS9N*%VD3R#ue7>}N0+|_30+Kr`uf%4SUx_NOJwN7w zz3es7f8caCl4wCajSibwD1@#E?iZN}jC#CQQ8Dcdo2`ER$y-Xkw$Su`wP%+$=ruy! z?Bs$U`b$}>?sS9wgVxW^FJ7KC%&bTrLA~=!`Nk&QjmMNZjp2FyTNm9)XD|M#xbI2JD;1=B`}41Zvfkgdg=s!5PJ`(QZFz0>OezOHr8Xg6ULd&rk11aNcqi_>P57);UV?+Ev zH%Yys^rC6C$Y~!JJqp}6TL3A9_i!?Vo6nx!5)gBh`~4*Wl&~^T1_B04`m4xuFG zeLzXV=nRI*qCoAJX2TGi6)vZ_vr)M7XHtChe90^oQ>LKX+u4|A0M9)%`ZyUx$7ZL) z!unFvcjZJ)0T%%nnjn=RV>+Jsl)KKu?n>W-m%m%eTez_0nZ@K}AXTO9YNeOm*i8ff zyzjpQ^Dxhce>s7>U%V`Gji+(iy;L_fiBB8XkcQ2QFkmHDp@4}Zm3xi(+TaYesBqPb zV&g~&H65!3z!#SWfve!4ZTaVc$~bK@Csz3$FV;^3F6or>A{og0zIYt3RFEz*>XkVj zPOQ^v5{u*!LzmFPQ$i)O)sptfL})h^h5e2g>8g)H3r#=^K+MGofU@hU`oH$Rsw<9Y z-4^#?A-FpP3GVLh?(Qy)I{^X&clY2P+yex6r*RGL4!8ChXWyT2pKibP8lzXOQC+oU zep4X6nKhGSL=&3*t#Rw{x&CXpxw{= ze+Xp-E>_uO0sdFhn^k42^=8AXK!BG3@BJj$1#6pBbBgc?O5)(VMG&c>_xSj3rYjGA zxwf)Cf;1W;OsfUrQjLZh@Q`2EFFNFA#iEa~MTBgxxQfX8k0$G=u&jaZyWO8`>&`9H zysS0kMf#Hn=M+(mGCl>54q)-d!%;{=IdS7^BYl>13A0|pD72z2=}RVe4h=2*4XjER z2nE;>@sQ<6q9Oex1Nn@&zhV@aJT=X8Are$Sof|VS_$a{a8Y6Pm5wMs>pbQKq_y~Ou z9GmlXTckM&9V|KqaQvT8lj3S$s*U@M`CWD%^~B(M_?H}8M3IUpPW-%4n>hDtfJ40#n}DkrSN^{S9#!|0r^%l%~3%N6AL*?{$Bj0JbNDkdS} zuUK4`c|0Dkt%LadAljI#h10V*Z{4w*Bn0fKvb+ET&)w1%Y`>SYxN1zyY{9D0WYYTi z7lPuXoN z;A+>oeNz?-6X&vy2o;{^+|Q2v;tSH-b)rYrEt{`pnrE3w&`^GXe-+30?|A>iS$_+7 zdTQx&Gs+)tZ%ojqv9JnWj8c`VJ2uq0?NelOne z^*HK-eja1>Ak{7@{}t3zyI5w#?9tykW5jDafpRS2=e59SLb@aS5c+uTa zJ(T6-nEo^+6tT*k^W%$fk&nS9eB_9O6)Qu$A5wCWbP=N-r_kL94NL5|q0())I3Qpr z@Z+quYj+2;5WQdGQTUphCwunOU{bb`ooX82I5TI9@r~6DE1d>{whqRE@)Oc_4QO-i z(P*J!Uy343IK!6^LX@t#O8d9Zd`P(5(4V0poFQ#&i0J8&hPu=bLcmk29 zHhOv0aP#0Vp)=6ptsI(l$y$u0+;86T9&mpw3ru|7@2T%dT8PubFD__}J%s`7=iaD# z$#15s8%%dylL`I8GusKUJ}d`CuKL^ma#%}6oU@dF`mGv%IfJ{)7FP~43qJEJn7$jN zb6jqED$iLnJpgEXP73;p5>Vfe&8ig1v1J%h{Syw{!hf=qq*SVRz6_HHaSY>rWSp<; z_YnYMIQ!b3?((&qwFhc4`eczvS>BYdjaEs_a~UxYavAVO5kLz+8 zhpqY#QG~OpC^3MU@YfBrPY_=7)nhvpO|ZPx7vA|Ws( ziyn2XuO~yib#KQ$9K;cF*rr!th$Nb0FMLB<2Yh|TxHsnAwS26?60mb`An@nRO%yUO9t>~N;#)>Q)Gk=S#N~bZC*LSfB zdP0vAl6NVOA2CSo&=3-Qog?Tm83u3}P}{wpY~va9v=^ALn@nFX;W^8!Q_B+Y0dk??{&lUsUsQrJ+~Mu)Kj4OJfTYe&XE?*Pc%IS)eB}I8T)x=fTF2vjjR)|8F>XuK;Q}XR&1L zztQxWqW7f+jhhS+k@uc-x_kH-SEGRUvtBe%AnWx|Y*si>=WPlKu<~9dE(@4=?DX;a zq4vWg_m;%QA$mi7F=k9KE7vPX^l&_!9&5WGI2hJ?$`Cv+0u3LaQLg&P5H>AjsmrxO z870M;axUzjGPDwqD4GVAI0o{w zy)-i%CU3!#@Gn)3!sXoV9t5OFD00dbu-?H3pBpGiL0**8JSl5pmDM!2!b2`8#Gu7G7kV2w#qiCBSQw>03@E!e$ z{w>i;GX1GjxjHFPiGQ+?dZED}Rh|_NuzPhcQDog}H}JQ?zRh1qh2UrR-y) zV`V7AvTI_@Czmh-V?`vqnQE!B8!DcL-EicETyM3dhG15t=}Od787bEi1|{BNQ(7Vi zX=r?Y7v6S0p0T@n(w(f8Jv^gXbwBZ!us1$}HkVU?I!YRs!>a!UpsXyj$y}627e@@) z>Lq1aam?RC@y8uPOoCEH0UVp_P%NV*zl99h75}j zwKPoLlJ~#bNd^MDXnwxjOjU{~=f^3tSh?-H@y}}rtoG>6kgN`{a}EC@kD@<<5(L2eGKY_dW}F<>%FJZ(5@tWxyh;St zqHh%E44}^+o^F!Xn2fsZXJ?9rNtE^JljGfShJx~unZ8=Jf{-chAX+v=%1&pbh*!Hj z!LyDQcQaM`ZK{L*H%jzjhBZg#HLSCyq+_nn5eWhtH&!%I+}y(}0Kw)e1eH7v`lG%07Dq zo5Mc1%MD)CX|c*>sD9Tsj~+Sr`z_tEYMeJT--qXRKbA_eqIMRT+@QK02nhdY24x!>rA+jM12)N)#~NH{^*hiAS9l)`ucYc`xr&av# z+}I`({ZcIwROgyq4Oy^Mh2eYuh?6IP^~FNqcq$4h_34o%i7!V0bZ#aRZ&y(P$66CY z`ePhOV$(?t?<^00w~uw2;zY=?3%*H5zVLy}B_&pYQg9*8kSi=CANhHv*Hez6W#?>O zOf)$@#=kY)Lj3;r4;hCktjeInX8e~*R21nC=)>b5g`X`r7F&7cxJlh24F3>1swsJ6VS{l=q;6TBK6P&?g`Ut|1DLZa- z4pt{6CD9P`xW0;r!hD5!Q%0f}4<97-%EE)RbFH&w(O`1gY-@J&d!11BU1KIXTWzj< z9ISUb?{U8BxBF>d@+BVHxK?CD(QpZONQCRG(}JfCLdSbDTAbYFbY!Nxa+r&vxKB&s!avn4UuSfNqKDx}k}wMoRc(L6#DhMM~HC#rVC z5GO91roN-f-p|6*|vytOvhfzW>Jw`tGw6WA7lCbhx<=7{AlAE`P>>#bL}N& z(_4`6#NK<`T;$}U#-V#4hkMNXU&}2-f_~f#bT$$_)q20t1bxr-75c^T`iVOMDgJQ} zfg#GP1}n_9c(sw0%zi`;|5A2%L_}Snz}g^>v2c&Wr+-}82bw{ZmzxSUKLrC6!t)V> zU9&vTs~WdQ@dS}382DW14OVU6Wq zbocb<>_~eNd78GsCRA0~n>OQPY$l3pySsB|aRr|Dr7~tIMMg#OV@g{E9v>fLyv)Yk zdflAiIUNB|OaTFvl(&dSzwiSQCnr>^O516}1^O=07aT|>R4c)Y6i@_z%BAt-Wh>RF zv~ykT-9NAx5s&FnDLzLEwpXX=97ius<#UO(H{vPDAztx)rb?E_OR) z`q(}E;CtgOXyTb-vuc-zO$aHj#Q1ccbG|17|Fs%BdBd$Y1Hx(_kaTmba*#T+J+v-a;G^*!e4@F);$ezZ@NcBW3!dkW~h-Q(>uJ zscQ1Rv9#Se+Qw7z48Ypc2Wk9)Xx(9Aim9)gp)exsu(?SM1|U^xOelPlo3Xi1WSv1} zL$&~LV-1A>Vk`*+E`lv zw6e!3rovi#RL+*~&+{#7Zx{3B$4qIS1SKKn#R!-+sHj=xKQx6_(N(q;Eo+1;uP_6` znyfeE2{>FI%O2cMFLk{&6gpr2siN{CBQBy4lxFZm_I9&sRWsZhqq5Li%){jEEQC~wtkRG7qhI8W2B8C z4r<>1jr`^hl*;lr92VV^!AvBT^3k&+Ni<@!pfeJlt3eqGLd7&9`flV9+77!ic**#Y zSU-f6vkg%MqHYj(q6=>$9)<8hpU(p^^RL|@>Y8d53QJnDJ*7aH9XR|~=_A1yTJ4x< zHBFAJo9*#50LFZIX<#+zBXu(tH}RDgxI_RaF-fME3O!qw2{?7X+13K&%-nm(cK$R` z+trrM_v!PpJjI@Z_FF!S6;Tb1vdkK>c0L$_Ytygj;?%ORzxpz`<422ZVXutxD`)FW zpe;7mSMpk{SG@SiV34ZSTlsGie>NTp4yAhx>0QKmU4)`V7<4{bB@0U+!|=n&&mYA? zsjr+fD`$c0ru!Bg8%F>e$=tt$Y)md|j9d$HC`g4=hIaHsO)1p_T&qUXHhN9gGACcg zG~)zZZ@p+D#1Ns#Lbv+;d?#54C-h>NxTsiKB--Gp zoA3^TW;}`h4JZ9}(Bpwe!*jm`xc!+}*p@>0k&w=VIncmlNCuF|k6QxXz9;m&U+XD| zB@x`h#6ZyQS{YZ}99cTOfgWqm^#cNAG8990JTYo-B=bqt60G=eIX;Q2?C6kwuP1f( z_e4mIiA1n4=n&jL<;uSp#h%ENrwQ|mOZC1g!`3|-9Hu{%&7J*iGh7{w?SH*mW8llj zLnK=#vbSDugw&Huy<(sdtwzz9%S{O9`H3v92Y(&I!kN=koASSD)yZWBcD3~kozPe!7 zK&nlu8=`0FJWf7>&I$=@&rUJOD22Ew4BoB2tFY1vsl{=+;)9aFu7_$f3wBzMWtYrF z8E*{f9}tti*br#$3x%sG*XZDlWXvfa&)Q0iNov!aPQ1-0yjbbvseaFLnoNIxI;0`i z{4#-r4WIoa8sKBOXoNMJ3Pr^zHOFCEA6ccCqc?efv1#>Zx&V6d`MMr51K{6vJVi|@ zO**&fgLJDsnKqG#(OR5ioH{~TaUWj8&Y!gN3+iyoGoh^Bzfb(dZTyRkgmK1U`=<^X zU~*OHR@)lMko)@gm7}z4)YKZsz%nc73b2@1i<(+m{2fX& zM@I-!X*C81Am5ssXb{+AX<>&S4;v_N@p8VrXVsQ*XUozc{0NEVNjGb+36<_hXme@+ z#|;OY(jx6KHkrbliAW1Z7NZ-?sZ+~fI?R=(0Ha!ti6H4FnH+y8vUq>Mg90=HN9rx_sR?K-0Ei>hSNmyaQCSnUZsc0{7<9 zO?g$++b`?qGxBG{nBX;#FK+$7M!P{m-Knd2;{$+)?d{`;r$S)t6K11@4?Z4;#U{Gn zS$7xgBLG!?(2e3vs1TRx{5Xi!7QNj!JAF&TdhpS{*9-yqv$Z^1X+6MKZ2SI+prHkD zIh!M=pnz6oFKs$lU2V3R=>R!hv;b);oZ8 zB^+??-ikj-Dtm`JrpEES8Yp$9DJTY-9H)fJZja~O{QW}p$}mL(S~oIx@;u>()DouU zObCOMIh{5Lg+S*!TKA{mkGi?-klD&}AFu>*;UV&+OXXOKXqM~^#ujFT7lck~dTFIH z)wsu;0!L3CyZ5XdxZ};v&P0=TYxasHN7vtze3v3(U_}>Qe8eI4N*nsyY~L9iBnxlL zvKtV6X!#=d%HW6xl>iat#lGS;s7z{<%93&dJG2nFa5$0Yw)c}yH?DEb8$!QCh^>eC z93GVr0o?I0y|rYRbI&>sQ>v!2RInlNi{DwJExQMo#=UM9_U7!hW@#qq{pQt9^ZY?Y zxg~Z>>|w$2dj609SMYV^QT3tOyxrMy-BLohjy!ys*wlig{4ZIzdEbmUI2Sw~B{JS;tT zsW>t!^^cmy9c1@un0w!97)y3RI?;=ap58Qq7HMt4e*Z(CDQ$25jyrnL^$qg{3>4F1tNl+9H@p<=1>uu14fU61lB4luk0{!!>I>|5_$7N>I;nwL8VeOC zRrqG?G3fXVQN}LEP*Z~8wVcC9wYV%WnKdDrbQr&S2^DD)sE|KP1WPq?Im~M3aoenS z0~ykaKxz(t@s$bPFlKkPS1!7VcAZ|kJ$_2?B)NuR?Yk&xRKD8G%*&~Z&(Xf7)PON%(tXn~fP%%he z$&`kSR1suni2mf#MrvCDyz->V)4Q%yb52RoVYpBtD4h)LlrqZ3Fi<|$XPF$!iw=4{ z4(NeyPNE392PKz8!7u(ZA<0^jF@zkfYuiCjMWN?><1~*r-Er)C*%EZO9il>o=%Nu# zV$rapa$nDDrRG9H&qN>2r|bYa8Ca!Aav@inqxx2g7_sY?g+pFvC=3=kag{;4&Kig< z4OkLnV_Ty}NJL1uAQebldeO%~0Gk!uOnQ^JikjnI5^e?$Xs8DTI^V;Wa}aOA`1Ap|Up$z1a@;=$J`-wt`X35Xg)Q!c}}oFw7XNulJUusQnwv zr`v9N!lMf*Z8E6`a+R^f4Qp@%sYUbAMY%MgwDC!i!;bp;IPda4{M7MyeExhtP#{n! zOwMIhVTLNDlRgR{l;u`alGXKGt5~p;Bv}r;>^rr$imy>2)4HF%ac%tUv(dY=o^4OD z9M>_AA|o}>1qVN&9?^4!3Y8%il&^`nuo!rH__|kG|JJL~~GCXjJkv9(LZX92&h3 z?5k4Awr-)MP-e91lLjK-59f9siW(l@n{GOX%3cO_$`uaZGyM~J!kOtW)m>ryE$K`G zD(JxRR&s2dLCdA63^tBQ5VeB2vldSk*K9E)^4J}^qp@A6;S54|g<_1slqV-~GKpD9 zOjI4B`AIB94Lqr+U!GjUGf8ViD6qd~&*4i5@?+M2cTQL;9Z!CW7YtExObb1!zDx^s z*;%O?&>iABOU5Xzq@-P*{SoC<54FP;^9i&o#diOvyCoXMfg zpu#oVJ^gj@`0Fl)p0Dnxbr`U#GBx3Le});tk@^}!J}r->)jD-qPK+)AM&aPeZD-x> z&)@VfB^?!K&PDXo9nD)GMZCIMvx#Z=)n8e$DT6VFkfnm?i5*sp6LN*tDILI*UfwOL;KNzy`%QH&c)D%ue?V`vzY)sR5VxKXO-A9md4 zmmp{aEXB{?rLi75+-_iI=g7(X!vXNr4V9SP*-c-6Vgwuc3AHDSAZRl}FJamrqOyqz z40uHsp$TQbZ2;%!)#yS79wMeFRTYROzrNLrFBDcCO_x_oZYZK4U-UK_78?A`B7Jz# z9bRrX4Gj|(7LB8trkvqQjW|49JUl;49IC+B@w+_V_t&Sb34)e50&Z@y8Bz?Uc}hJ- zPGEuGxBsNd)u}Hjf-@h`BiO#w(J%HHb_Os-r4ZpZoyLRBHq-r4#0WH^t}N9V(ez%9sy<`NALV!hyTzu2n;ei8A;7O)pw*#9hlt2egSl1hgrA(Z z>b&cF%xJW{dwzR}*2q?rNEDB@kL2)nCyIGQ5M%FiDA`rfK$Pn9rK9JEgHz;VpBOw9 z(X2jUGidlM6|0LwV&p_#a=V1tjqCK43`+Wl3g%qW$t-51(Tcg%;byVA_I%T~PC+x3 zO?xZhfrwBtqTMH ziw_K=+s$~~fF5cDm4oh#)9~*zu>e6gPR0BNdq1|^DY$Ml;1GM+v&*V=iZTAV85Q&; zmMHDu3vTp}AJZRqr*k^M=1-=p+2oCjA*^G?<+Hy1mfWFe_Hjw5SyV2R_rJ z>nlD0rtg~CR~NZicgvlmkc=&)dg?*0W#({#2oR*Zddg8BHpJt2p-aT2e`{hh=iEIhm};LV2*aFcK4W@SBC z$R~A09qIInPrKKUJ}QZ|M$83hNB>eAt)_fG9DKvWtx?2YY>Yt`;alU0f>2c9e6-hnMSQ z4kuK6{wgES-(zt765qO&=ff-M)xM3HJl3t-)<+Z}h;sc)kJi^C8KtevL?UK1ZI&(8 zYdO>}`Ycsl;U+f$nLJx%+JnZ--~j{dRftqKNf1(>he^h}d^K$UoeclSaC zH*`g?V6$3|%b9UiZL%NT9vhL9iHR(=c7PwPVW#iL5JMT(hBW@P7z~SoaCC!D(YndQ z!(fuia((#imZ$r&G6%VGMICNmek`8}+p=M{%Shflx=;tcoL&E})GF%+EPuNB444)Q zZ88!1TqSw49+b%n(#)6FYqZeQzq@IcvMInX*+*kfG$jl~oOL{O`<7`|<9j23PpL#& z{LWWeS-M8gn;hPjsuUEsLGOOWq|)eQG@nH!BEQ4C>|ERE!1~!GV7FtK^&_07rxOzs z5Bc2v+di5pT1))y@vld!YA^7}lKF#Wc*OACEPf73T2HUpMC`2uIjy_Lcj)nCQzSr@eMy)kgK*_lO91|KQZ8*dqN!r68Yw zzH?}(fiAOSW`v4^^avsOHu96)CdisMEh6{tq{x9jNk!0@?i8#_WWwB3LKc--Js7%S2+xymA_)(qBy!duRG zPZWAFe6k)oLAH)_waMXF9+&rVPBDkK3m4lmV!jDNYOIAv8>1FZ&VQyb84{qA5W5`Q zGIL0%cxpYfRUC+*;~e!wCrYz_nXTsM4c=%RV!R{y3u}O+K*z-j)!9Q}M%>#nAZ@LU z;Cqn4v;q`^pNe%FCD{>a|&S8P#hkn~wYS?Z2L?wD_jt zSJ&ZDOL$yJ?O1&hkeQ)&?q8jzwuP>GXsId+F=??2XpsQX6F)3^T_RQRpM|Qv_nY$= zCA}60PC;sFI0iR)1-4_*mR;lQ+#JPyX&~?Yu8te#N2i|zCJeXYp%kMd>s0x?!_h;> zmp1`e1Aa-{?smU!&VkM)oO-A5Q+#cs{LaRT3Vp~)%SOxywk&ap@FYV4-*&)*rKk8_ zp*{(*Z=RhEl@+rcPinAIp}agxx94F)iX`BE1;;^OQ6mW4NGTIS8AZv)mLyv2Y4N&G zb-Vu0sm@TuC$^CGQmLk^>=#nw+NruqHo%RrJ?#zl7t)F?pH)hf=51ercDfh7eQB^L zucbthk#6-^J|*?2j;I>H4TmOKh+>`Wj^xOR<4vjcI!2dJ<3>i1V#u9tBLrb zg|Y)DviJGshwD$Gq}GE}JzLb$ znrAx*ip-o@_kcR@m1~-RDE%&Fq_(G>(t8ED=iP@1>L9e{gjM-V_$#L&{!%)NBe9!Z z4*~P1b?Ef|G(QUDFp*|-1i00&)pBCdS>Dsb)-rIii-_=-6YpoTJ;l5Ww0A;ry$o`acu% zqQs_yCvoR)0RXssAOaN}_!0WhiI0c(L1*xsL#O9KO9O@P@{yU(qc$ zcL37*x?j4=A#8t)h7!c~5hIKYel<&;6D7Ud+e-lf?8;LcJUp5;y9y=|6Qt?-?(URH z5ZF->E#BlXoDwDa2l|Ww!WyYN;!nA#etfPUASxP~oT|~aWe>qTku(0f|cBj3_2*-W0IrniHFy2siyg>$=_aqIR5)y^A0eo`T143 zW-el=z&?Vo>{DbOD0+`4`ln&+xX0-iw z!pCJArHH?Ginv5XBcSKt13XzX*4r(TmVl{im%4SwPaKlf?JMe5$&4yOvR;R$SV!{S zmy0v`wFOlb_6nPI-TiV0;ipShS&<*{Ka-1BX%>Unt=Z(rTAu6-dpLo&VT2tLBvRe^FP1 z-h&W8D>yXx2ljqK=_a$ZW}B|7LubtoG%7Lr5FD#m{NX<+`InCJ<_E`d!t~cguYH@d ze+yY_jQXw4o<7gHa1|9lLP(?9hhS8adt+0b?4jePV_h?N_tH-q7(zMu)HHCa4Zi+7 zU2of6(~5nV|}35tMV4GOxjC^K~Y+Z|Hf{9!<6 zotdia^?YqN@5(Z8U^Sp==$&(o8QpB_>7{T#BG?G48!k<~U0MAdF+LO)v$R zN_l4DycSVgByVi;+e?##Q=DO|^Gt8!;gr;9gM>4$0W0hX^)FWhLL7u1QAYQ=5f9pz zKkfz#zze(}MeNUQH-EG(m&IYjo}=m`h~935_YxFl*e8`!)`rCzD zU$Yds5XN-BGr9N>$2#nMvZpL27!D3=)PAA8&24!)OyB{$w4HKvbhLZKRJE0~7B``! zpb#g$*M2JlQo!ccYA zzPzW$yGn376RB1-R$)e`BK1gxkLKemkj7i6}soDXfvl)+pAUOAkNKa>g! zMa#m&!i;S*+a#wC7fB0VP%?O&AXJ)?lT#kXbGx>LXuAJeSNv*+m}#%5!I)+R(J?{6 zu6eEU)_ykl%S`jBD*NSlgoW$RKW|DtZAIsI_`?JJ%tRaUR0+?UiRR{}9KP-vY=VV2 z^pI3<9y9|99|3h6y*9tQKV*m$l6*uzz{erI+)*G%{R{LbMm>H!O5z{PL6p(-iF;ha zu6r2BexWrYLlHBc(Sx!Ljg3(-c)rOA`J`WcpO2ZAhGOyBew|mh2-~i5^l*G8(YHh( zEl9(ge-FfH-L&#=+M3a@TapKiscyU^SPFYJisF%l2-pz}m_w!~SgH7_(l_tG%i%+Zrul^Q3<*BHinzE3gH1Sv^lmEW`oZoKMHqV$&2doy^dAkin@$Jq=B zq2ce1yicmH)J{Ep=%Ac_ z{}LV6j6pHHyDb~Ll>h9>_eZSCw-8nZtqeY^)Zf+{>I6aor`?_{ErC>=oY$n1jcL#B z73^sG9iG1931aY)ObUuJk8g2uoau7p$ux^m=E+Ro_uQEe+qF`}6wmhWSFo5d ztFqHjOJgL&!{!3sp3U!0SNwH=sGH3u-H{KIDw!>LH_F>yys_-upcnsv)ST+W0RETI z9GhbW#s&>%`b>Gc6k`)i1na!Rj|`ayBHgsgMc92)4h0kJ%0F0xB{rF&fO+ikPXJu~ z<)K#H-Pf0zBz*wMoiNra2Ncv0R+>!KTix6&$vz+P(ocPh41lV-XhG6vd?}E_oK&Vp-cy z1CI=tk|c%GhV6vK&Cb`uiGa9IPDcNS;33qAF}TDuo}Gy$j}8J&X~h}q9#B8uGJ;>G zF;?&z`YME~S4F@JQH#YV+~o)1GQ|g_xEOJ+j?IW-%~MsXD^V(uHR za_ttq8ST2)wT<=XdC7ftom3f3PwaZ#Dshb*=N}`si$4|)Cv%!xT#~4nH7l`US5Gqo z^}>~Es}O{9pSAF$e0l_S!AoDlb@(OXNdusZ!~3516}=$Eb%WO4I+L%5aZdD05UKct*4*0PZ8k>_T>pI6=AFresf(d$knCM~UZIB)M{ zNTHJ0<NA$cgLcebt*T4*K8;SB!EYb!nHHZVGRhk8+|8s1x=ga_V4lJst27~C z2fQx?XE6&UZS!(e6dqS=&|$d>D6Ibds*n^R#%|i|LT*pSDIm~-G6>L;@-|i*Tx#vyWZskVeR6ls%^s%5T8tB z#6{FqL|~#gBw^{lMP`kSXy4qZ`Vb)#b>neoVu}ZgOk1m~V|gEBl-1q>F5NbBrB_a> zvVnt;pZs86`H`VXdVkD|)o~{#YVy}Pg1^&sc$RRux3MRv(*MK9prhP4aH&kI4nJpl z8AVAWgXt}o&yQ)cT~H-rab4bc;d4*N$NTJ!Fw#jr(b{Wmr>m=byI6e;y;x-^@VfrA z*-w|!`@k4%f|6m9A07YDI*RtjlsNqqF(w|xnl@HGG^A4kici^(mk>#%`(^1>@&hm8;5@&T z)wvD2;DpzVGb_cMk2PbE{9gh_f&Ew)(^rvK2KTBp94KLfatw1ofde_TAyfyj4f9K?MjR_^l=R&)8U4ttc|G zO2Tm!^rZWvQ;=&Li>v>!krV@DHJg9xzP(_$<78T{sy}}>H#dg~Y_NXH_aInA6M8v$ z;$DUUme*ic;t3Flv^-7O9@RPA?{gcSH7w(Z9SUU5OB5|ev^Sj1m zI6GNqt`jjSc*rzCx>~p1{^Pw%NJ3KLHi5E%1bn(QK={$wi5_(Hz){K7y+4-&SA@zT!q5Tag=bov&yjw3-)$6d`VWH?H(%x*Feb`7G!&?7CS#yZ!n;5hSXVMy)FS1X;A46>VwUzmHc z2*Ps(mjw&4>E5I_p|542I%G08`;u+GhvaOZ_Z}H9j-=<_*U() zHvdLwYW|u*f9Q!U(oh-`_k&mazz6K(al6uJ8MxZ&@>)Et%$J*?)$6Ucl7+H*L8Iu$ z2UKy>Xn>rlK`88Y(#Vhs>#ZYKE!0~-G-70dj|X-1zC-e{%8si6`q)2z)KwzTlv-EM zo(^$_HEW?}P6IXXS$f|IfNk-v{NGTB_~%>!_|?GM?Wz&qhf{!$y=J1Oei`FC*6-tZ zWmvr4C-^Glt@Jg=&hW;V3@O+N%B77^IV}V1<`iLvj6KPRiH*sgik=vZ4W~Ht;|Byx z`Z?J5o5}Q6SV2$r6V>Ftss*T6+VLv2$6qXv#)D!gEbDG)>X*&r-4Ue@4C@jQTkHM% zqZcw-T78*MgRVRD`h-NQ-P8;8A=|`}|GNW|vhvKiI+`6zmamrzBMpBlG%EpDf4iK!zZ7wPSL$grso!CAv{Yo7HGHNqg82V)Ps3=@A-TB# zWz~&FH+R5Zuv7O-%@Pn`xMpGQhX)mnSvOz2`Ta%*a->@&DN3dP!Y+O z54#a6P5S<6cV##3<0dX}eL=7qI7g2Ga}p|mX73h;xqUN6=k~rX-UmE-T3#;)%FB8L zpC)cPKxIo$SHmA8!aEg6S$Y`OSvc<7i`@3H9z^exgNMeu$QY+}eXxv?mzN0FM@4b; zpiTe7rK?!{;wJkQ>!d-`rzq8bD7e#g6eZt**Q#6uFCA?M(nU*V-ok2e&Tg?rG5zUk zyj|%1QYgBV1rm2XXYoL>4%P&B%S~2&K_WEdY#ohxEHO2m`}rT6K?iYbx#qh441KAI zkAm?e&S}c9+N8Cor)TV3S-^4gV^RtDU_3)_35ec?!{+<)Ypeu6%Lpm81ESASA4p8u zNBV?pq9g>Ckdc??SEKkn1kX!mcjoDes>b8;{Yy9mChugNDcROf_c&gFpG6^sO4Mx! zdam)Ynta8iKqv(7yN;knh+#{JG-#h@GK~EgB)0(yl(inM9RqDNxjwgi8Vg){)w5)x ze>L1|agbd*X(7;qcE9dWa=l&LPEA#>)fH@~;o)(;dF<}a|7T`*CGF~0IK>u~jye)BcLI5%bP>B)z^{YQ6s*i2g^nVy>uW_ zD`NU8xcmX)J*oFbOQKW9$K_YB?VB|#RR#FSc~>!rh-@&~HOJ7B6Frgw0 z?h%1APTp33GzO$N1copSI;0zUP?+PSOP?kgR3rp>5CSQbAaKYy|E%xD@;~2zMq@>w zL^Z*~v`qiMzY*`H0b2Y+keMZc9{#6sJPHD{oo5(UFC6IMe>!>$16q`vw+H{X+sGgu z3}I2O!&c+U|NRX*WF7$fJRmZ4;{MN2fR4V=0I&4{A&&i*|6Y9SGteT~7mh>m-|-!Y zLm+~)cSEX0|9f#iQJ{sU_0shJjxQT{&$8U~>oorF#g%}T7Z@DFnE#G1{WEYY+eG6k5V;kFyEq&?t7yRDm-1B^$`?!zy``mNx`+eW%hFPMJq9Vsc1Ox;`jg1Vf z1q1|1TaCDP=T?uD`dI8%s92iW8k%9SfB&D?{M<(FmVN+djj|DVTBVbGpw*45JY;#& z`(qTki6+0rguRU%eFOyd9o%k$Z3BTD0s`V^j16>c{XWbL4BX_Dfp%HOaGg%itZUD# zCeL_@$mQomt6E3QnqGF?eGh#pDcnnxFzYfAyAW!oV3;{Dyte98H`7z98)Xv1S-@`K zGrl~YyApPn59??ywOj7c`caG&UaREU7+Vv0c{lqx$GEODw1QN4&HVX| z=6Ad9EJgrwuhOJg5|fd-sq6gHdfX=@(NWVQXn^zoO|-PP~ZN6#P+b(* z%gf;@Hb7ZWlUVRQQRsAfBBx&Cj9I6btW)TdvcbXAm&8Q|>#ia_dWW)_r5T=1LczL& z4tK$;Ct~QmCWDE%zMjfs?zvQrd;O*r-OX-^0bvhv%OgfITv}Dwj0>*vW#u4vQqE{% zq5P5Mj52?9YX}3P5qRyc=zN3{)}_MLF)5P!J9VTw@aSw2%8CBZ8F)KKmV{G<6&?;BH2|^&964g z&#=>z{N9({DpY)RM+IS&>lfOwdeXiinI=lYU7gr@ z-E-HtKc(;jWE!UY{aH%ovbCojVo2>tD<)W&D7BhwZ+VIzWyZ;_6{@I^x~N2CIEIM@ zE}xI^yy_{15V>1(Bf#ho?&=UV{~=^+0cOMwA`OMU+a$;KBauQXN@v@C&Mf9Km3CQB zP`Aim#uQYg+{BFYQ8yuJ8e(p}$$HSd$j)l-^A_=_YmgjuV}F=$%-U^W*{CugbG!O% z#Ph)fqfn3{UVoM`G%XTEb+40B=eRFnE{0*gK1valYk~^{s($^rh1JvhO~Jlxt3w*! zOE~U}&lWFE$t~(ac8%u7{t{}`PmXO6x|F3)*$V&r2Z?5gDg`EcFAxVBU(ba@`itX? zknF-AMPrmqyxV14I0kR&c`;_veO@r!bP^Y{;^w#4_u-1OxIMMqcsQ7C7{hVr_MEG1 zvu11+;;sY-=(ETrSUY?k2$?eY>6-zVJQ`|97iDi%B0TI%za4Dlb;*0*;@_C+Z&iwU zLXzBF3>lu|Chl(KlOtdkvIl7#6RZROg!l#V%DGIz837_Cd+LhpLFqMuU%fG#Zew9!D76@pO?FSlU-Ko3hd@X zy2RIx|260af}VU6D~H%Moru{5Z<8>M=<_-fMRqZ@JE0f~8dX)Pfkng{K;KzRV&gHm zo2!y2X}LDxF`FWSTs%sSoPxz%AnG9GFHhn|RwSooj58!^J^g!6rlBOshNM)u753TTmYs+1d)TH-%S-*Y(4D3Zd+;sgt=yQt?+MZT3y{M{modU{lk!^wb`T-|u z(-b-=%c}tyeRq6s1GQu?h0YtxN42`~9q=UtlyU1N7oLqE_x0Um#AaiKaShGS_d2dX ziJEcK(OKm3*;|UnU{gN)K!VXk_kf1R+Y^p%*XIFuIJEv3+YSebYW@q@ilpoJeS0~* zv%Tkom4>7<7Knl;iE;u{6f(uGdIoDh-C=tksDfv^BFN`~R`~bJg1`_w3FNCH1xplj zAtXZIMLq{jRV|gCTD{GV`(QQWh{fCDy8WwH*z9|cGS)Nf&g0{7el*cdyIl}9a3Dzl z@dVz;Y9rs|TeXK(>CaN>g))B(*B_i;&)|4lNN*^(K&}%{UjBq=fPbn?Kpdh)7J>eJ z`c`>!$*L=?MxRa1lepo$W^)g52>#5k9&d&dIbqUZV}?+N%cP5LN<~ISN65ES9M*=v z{849u1Z2DN=e}Im*spEgTmHHz*L&sNBJ;PJ+ee!Rka8c8DVuc?jViT!9-vok*>1m zb|1DZ7Z$`{PIB-L{R4_y^kA$PtA89l=493!c8WiL--XfgSAmE1a_+eq)v@NMk>j1y zYy$L4UoGi%i$`oW!Cdwi=s})eDL?G`z!$OKJkw!+!3D^xJssH{j`3nhACp_Yn;oz4 z4##YK2Rc?-Rnul06y_-yqZw>j7FPySn{(&ON_G_?Mf|s2`A&s$F0-2s%)VOMK)e&y!(d`bJ_q3Fb7c&{b;)oR|o9V3|zT(3p5{Q$0uzeZ@o@{#hML zj5I?|J!(~BXEg#L7I8Y$Uje(BvnFG*?Tp^t@|tw*=*T6E;xz?^;Y3*aytuXk@-^}i z$Lb(tHTF#$qCYdRG0^G^{y@w7wy%OC5(5WUbVxhW4??_?o%|q;D!r;aKfQ>)eX~2C zJSXiCV=I4eMPy7!lZL?3oJ8!-ye8t{=8wq8=b`gBgvw3{w69$X=^$1Do+heW zGKEK}-1f@{B2z$LTG+RfUsngeR%Th-f$xy5y6jg{6BBk1TLm5ZFTbTydM{CV^^5^G zc9T{A5~w(QX~x!Thg)xz4xwja9R$<*Y;?TIV5`pJr{fN%etUy^3CxNz*Vd$JFEEFf zH3v7Hr=Iy2sA!39eI~rLHD8mIF50AE>GX&qi^WAjZTGCVo~G64Z0a<#Uu{;3%-S3z z)j1=3Xshe`*xpp2w@Me;Mf2~^Q{${kUe5HOE#JTB`U=rRwuEYtIubgv`QR2+T}QOe^t1N0fYm|MA-{>pFzM8fcf%+Z>GD?C_b{9qi$8f>CU12hC{7!X6%^ zg&-ZFsV!4h=lf}H4-M2IPO6i~nmsv16yPmQ)O}}e^_h7$1oLmZ8`kg02zCAo(>B;yE%8*%zo?Sk;_NI9~ z9?TQ$pv*_l#YwJ}{mCAce=^tqs#+*N(r#_Fs2r#g$k4{m3~3M<<9Z7!7G!EI11^;>-XXxzlX|oq=Xk zv(OXhO!PQ42R(+)MvqeS&@6N=dIU{D53`cdLue{>j+H{4MW<0`SgF)$G>tm7^;&SY z{`)6c8B{hpi#ox|q>iI=sAH^b>L@yo%3|eGM_3fHVmKAiQ(N~VwPpPFiXQs%p#==vv8vwGcTOX z9IBz)%t`3cdI@&4PUuTinphr+XmNxHgWMt>BkRDW5dH8m1O@jB==L=Mo=-}yai`)| zLzrh4!jf%9BrIuF&=<`G7Dhxv@@aTdsGP3vg*488=;Ic@f|{9s>fXtYKkf=!v@zP3ug(-e+5cDw~E%Pj2+)PNf9tML>c7jxjp|MB`R8< literal 0 HcmV?d00001 diff --git a/go-backend/static/manifest.webmanifest b/go-backend/static/manifest.webmanifest new file mode 100644 index 0000000..5a512f1 --- /dev/null +++ b/go-backend/static/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "XTablo", + "short_name": "XTablo", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#1e1b2e", + "icons": [ + { + "src": "/pwa-icons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/pwa-icons/apple-touch-icon-180x180.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/go-backend/static/pwa-icons/apple-touch-icon-180x180.png b/go-backend/static/pwa-icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..78da98fd5613202f0c6b0192c93ec2f1824261f7 GIT binary patch literal 12129 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 b9$uD*0*27-=(1il^9Ud0)#a*X%!2<1+mk6} literal 0 HcmV?d00001 diff --git a/go-backend/static/pwa-icons/favicon-16x16.png b/go-backend/static/pwa-icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3428da3e189821e899382d34394e07d02b1fbe GIT binary patch literal 600 zcmV-e0;m0nP)03+ z?w?KAq)V#KFcOK=w5MJ_ACJe9RQ7r{WO>kRwCMW}VliG-hvaUHrALOraaoJS;c>4! z>@J7hEs7)9I)BXPWjz9!O{v*EskoUYB5q|@nA|=lAo6RoopUoO(nN0Sd0oV??Ty7bR zY&M(A zuPldCsflDF<@1Fol>vB5%L!fAr>Cc@s%ij4qYf%F=Iietu)|sa#;)zI-wQw}QLC#q zOievVr_(ue>AHUS@Nob}u&+2+5e$|ihXlaeT2I=$v2o)o04}Exki-yxqoHA+9Tx0z zl8JFmjbP-(NIrU`-QMx6`M0^rz!12`nd$&id}MFJ!2@joMw9&hvREvcH>r6% zFzOu{Nod+EMpM(VnVIy|)Pw8Se@iA)r%!iqrb%Mx#*Len%fSP!01RVKAW)WwrxtS! z3Jbt+OjSz}@(|)FTd_7&yjpY@Q=&K=E=E<(w0PXe^uYcWv-w`Xe?@FGKlj1_HbyEn z5e!yRq7p143CAbn*l(5+bX0hq=&qEl=oM{e+ z$eHGm!jVXIdHFg*yvQM`N=cqx6$Y?=4-JhfN(sSU0N!e{N!Rs>tG_sffXi9b-PJoe zaqah;e>{5hXC|F7`N1hM^lEr`R<8;JSp69sjLNbVU2JQ6!qwZi@A|b>0Iv4-&+}&Y zaI=LWo4PwvcuXx`Sh<@!BktJow#C(qIqY}t-YW`}Q=$Oi#zRLe64*p*G&smG^4N7O zAe!|OizOAM1UV#APuTl)A6e&5&O9~WeP=I##F;8^)!=#4v3Uowk-^a;?HI`zdsQ`( zNQ^Cz3d^Ft{sRCci6N&D0B~-8dE1;Ak24h`U!=79{{5*#2ipJ`B|g8u zY}Dkc#q(L+#fEMJxBw(d6iQ^qqPw`ds)jKcAwy^4b2{@x zuha;^K1mD--0yY;8B;iydwTi`ADQzpIy=9k)WG0OLk^FfyFGk!7nZoHMjRkXV%W-k zar7mb+}YX7xeS1UMJwa+v8Appqa%qxU - - - - - - XTablo - - - - -
-
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
-
-
-
-
- -
-
XT
-
-
-

Se connecter a Xtablo

-
- - -
- Ou continuer avec -
- - -
-
-
-
- - -} - -templ LoginStatus(kind string, message string) { - if kind == "success" { -
{ message }
- } else if kind == "error" { - - } -} diff --git a/go_backend_deprecated/internal/web/views/login_templ.go b/go_backend_deprecated/internal/web/views/login_templ.go deleted file mode 100644 index 00c9b41..0000000 --- a/go_backend_deprecated/internal/web/views/login_templ.go +++ /dev/null @@ -1,102 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1001 -package views - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func LoginPage() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "XTablo
X
X
X
X
X
X
X
X
XT

Se connecter a Xtablo

Ou continuer avec

Pas encore de compte ? S'inscrire

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func LoginStatus(kind string, message string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var2 := templ.GetChildren(ctx) - if templ_7745c5c3_Var2 == nil { - templ_7745c5c3_Var2 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if kind == "success" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 91, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if kind == "error" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 93, Col: 64} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/go_backend_deprecated/router.go b/go_backend_deprecated/router.go deleted file mode 100644 index a6ac3c5..0000000 --- a/go_backend_deprecated/router.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "net/http" - "os" - - chi "github.com/go-chi/chi/v5" - "xtablo-backend/internal/web/handlers" -) - -func newRouter() http.Handler { - mux := chi.NewRouter() - loginHandler := handlers.NewLoginHandler() - - mux.Get("/", loginHandler.GetPage()) - mux.Post("/login", loginHandler.PostLogin()) - mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static")))) - - return mux -} diff --git a/go_backend_deprecated/router_test.go b/go_backend_deprecated/router_test.go deleted file mode 100644 index 986d3b2..0000000 --- a/go_backend_deprecated/router_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -func TestRootRendersLoginPage(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - - router := newRouter() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", rec.Code) - } - - body := rec.Body.String() - for _, want := range []string{ - "Se connecter a Xtablo", - `hx-post="/login"`, - "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", - } { - if !strings.Contains(body, want) { - t.Fatalf("expected body to contain %q", want) - } - } -} - -func TestLoginReturnsValidationError(t *testing.T) { - form := url.Values{} - form.Set("email", "") - form.Set("password", "") - - req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - router := newRouter() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected status 422, got %d", rec.Code) - } - - if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { - t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) - } -} - -func TestLoginReturnsSuccessMessage(t *testing.T) { - form := url.Values{} - form.Set("email", "demo@xtablo.com") - form.Set("password", "xtablo-demo") - - req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - router := newRouter() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", rec.Code) - } - - if !strings.Contains(rec.Body.String(), "Connexion reussie") { - t.Fatalf("expected success fragment, got %q", rec.Body.String()) - } -} diff --git a/go_backend_deprecated/static/styles.css b/go_backend_deprecated/static/styles.css deleted file mode 100644 index 6ea5f70..0000000 --- a/go_backend_deprecated/static/styles.css +++ /dev/null @@ -1,417 +0,0 @@ -:root { - --background: #f5f1ea; - --surface: rgba(255, 251, 246, 0.78); - --surface-border: rgba(84, 61, 31, 0.12); - --text: #1f1a17; - --muted: #73675d; - --primary: #1f6f64; - --primary-strong: #18584f; - --accent: #cf6b2d; - --accent-soft: rgba(207, 107, 45, 0.16); - --success-bg: rgba(31, 111, 100, 0.1); - --success-border: rgba(31, 111, 100, 0.25); - --error-bg: rgba(181, 69, 69, 0.1); - --error-border: rgba(181, 69, 69, 0.24); - --shadow: 0 24px 80px rgba(43, 24, 4, 0.12); - --font-body: "Avenir Next", "Segoe UI", sans-serif; - --font-display: "Iowan Old Style", "Georgia", serif; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - min-height: 100%; -} - -body { - background: - radial-gradient(circle at top left, rgba(31, 111, 100, 0.16), transparent 30%), - radial-gradient(circle at top right, rgba(207, 107, 45, 0.16), transparent 26%), - linear-gradient(135deg, #f3efe7 0%, #f8f4ed 48%, #efe9dd 100%); - color: var(--text); - font-family: var(--font-body); -} - -a { - color: inherit; - text-decoration: none; -} - -button, -input { - font: inherit; -} - -.page-shell { - min-height: 100vh; - position: relative; - overflow: hidden; -} - -.page-background { - inset: 0; - pointer-events: none; - position: absolute; -} - -.orb { - align-items: center; - animation: drift 18s linear infinite; - background: linear-gradient(135deg, rgba(31, 111, 100, 0.22), rgba(207, 107, 45, 0.1)); - border: 1px solid rgba(255, 255, 255, 0.45); - border-radius: 999px; - color: rgba(31, 111, 100, 0.6); - display: flex; - font-family: var(--font-display); - font-size: 1.15rem; - font-weight: 700; - height: 3.4rem; - justify-content: center; - position: absolute; - width: 3.4rem; -} - -.orb span { - transform: rotate(-12deg); -} - -.orb-a { left: 6%; top: 18%; animation-duration: 14s; } -.orb-b { left: 12%; top: 70%; animation-duration: 19s; height: 4rem; width: 4rem; } -.orb-c { left: 24%; top: 8%; animation-duration: 16s; } -.orb-d { right: 14%; top: 20%; animation-duration: 21s; height: 4.4rem; width: 4.4rem; } -.orb-e { right: 8%; top: 66%; animation-duration: 17s; } -.orb-f { right: 26%; top: 10%; animation-duration: 23s; } -.orb-g { left: 36%; bottom: 10%; animation-duration: 20s; } -.orb-h { right: 35%; bottom: 8%; animation-duration: 15s; } - -.auth-stage { - align-items: center; - display: flex; - justify-content: center; - min-height: 100vh; - padding: 2rem 1rem; - position: relative; -} - -.auth-card { - max-width: 34rem; - position: relative; - width: 100%; -} - -.auth-glow { - background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.1)); - border-radius: 2rem; - filter: blur(24px); - inset: 1rem; - position: absolute; - z-index: 0; -} - -.card-body { - backdrop-filter: blur(16px); - background: var(--surface); - border: 1px solid var(--surface-border); - border-radius: 1.75rem; - box-shadow: var(--shadow); - padding: 1.5rem; - position: relative; - z-index: 1; -} - -.card-topbar { - align-items: center; - display: flex; - justify-content: space-between; - margin-bottom: 1.5rem; -} - -.back-link { - color: var(--muted); - font-size: 0.95rem; - transition: color 160ms ease; -} - -.back-link:hover, -.aux-row a:hover, -.signup-copy a:hover { - color: var(--text); -} - -.back-link::before { - content: "<"; - margin-right: 0.55rem; -} - -.theme-button { - align-items: center; - background: transparent; - border: 0; - border-radius: 999px; - color: var(--muted); - cursor: pointer; - display: inline-flex; - height: 2.5rem; - justify-content: center; - padding: 0; - transition: background-color 160ms ease, color 160ms ease; - width: 2.5rem; -} - -.theme-button:hover { - background: rgba(31, 26, 23, 0.05); - color: var(--text); -} - -.theme-button-monitor { - border: 2px solid currentColor; - border-radius: 0.35rem; - display: inline-block; - height: 1rem; - position: relative; - width: 1.3rem; -} - -.theme-button-monitor::after { - border-top: 2px solid currentColor; - content: ""; - left: 50%; - position: absolute; - top: calc(100% + 0.2rem); - transform: translateX(-50%); - width: 0.9rem; -} - -.brand-lockup { - display: flex; - justify-content: center; - margin-bottom: 1.25rem; -} - -.brand-mark { - align-items: center; - background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.2)); - border: 1px solid rgba(31, 111, 100, 0.16); - border-radius: 1.25rem; - color: var(--primary-strong); - display: flex; - font-family: var(--font-display); - font-size: 1.3rem; - font-weight: 700; - height: 4.5rem; - justify-content: center; - letter-spacing: 0.12rem; - width: 4.5rem; -} - -.headline-block { - margin-bottom: 1rem; - text-align: center; -} - -.headline-block h1 { - font-family: var(--font-display); - font-size: clamp(2rem, 4vw, 2.7rem); - line-height: 1.05; - margin: 0; -} - -.spotlight-link-wrap { - margin-bottom: 1.5rem; - text-align: center; -} - -.spotlight-link { - color: var(--accent); - font-size: 0.95rem; - font-weight: 600; -} - -.login-form { - display: grid; - gap: 1rem; -} - -.status-slot { - min-height: 0.25rem; -} - -.status-banner { - border: 1px solid; - border-radius: 1rem; - font-size: 0.94rem; - line-height: 1.45; - padding: 0.9rem 1rem; -} - -.status-success { - background: var(--success-bg); - border-color: var(--success-border); - color: var(--primary-strong); -} - -.status-error { - background: var(--error-bg); - border-color: var(--error-border); - color: #8f3737; -} - -.field-group { - display: grid; - gap: 0.45rem; -} - -.field-group label { - font-size: 0.95rem; - font-weight: 600; -} - -.field-group input { - background: rgba(255, 255, 255, 0.7); - border: 1px solid rgba(31, 26, 23, 0.12); - border-radius: 0.9rem; - color: var(--text); - min-height: 3rem; - padding: 0.8rem 0.95rem; - transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease; -} - -.field-group input:focus { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(31, 111, 100, 0.45); - box-shadow: 0 0 0 4px rgba(31, 111, 100, 0.1); - outline: none; -} - -.field-group input::placeholder { - color: #988d82; -} - -.aux-row { - display: flex; - justify-content: flex-end; -} - -.aux-row a { - color: var(--primary); - font-size: 0.92rem; -} - -.primary-button, -.google-button { - align-items: center; - border: 0; - border-radius: 999px; - cursor: pointer; - display: inline-flex; - font-weight: 700; - justify-content: center; - min-height: 3rem; - transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease; -} - -.primary-button { - background: linear-gradient(135deg, var(--primary), var(--primary-strong)); - box-shadow: 0 14px 30px rgba(31, 111, 100, 0.28); - color: #fffdf9; - width: 100%; -} - -.primary-button:hover, -.google-button:hover { - transform: translateY(-1px); -} - -.divider { - align-items: center; - color: var(--muted); - display: flex; - gap: 0.85rem; - margin: 1.35rem 0; -} - -.divider::before, -.divider::after { - background: rgba(31, 26, 23, 0.12); - content: ""; - flex: 1; - height: 1px; -} - -.divider span { - background: rgba(255, 251, 246, 0.72); - border-radius: 999px; - padding: 0.4rem 0.95rem; -} - -.google-button { - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(31, 26, 23, 0.12); - color: var(--text); - gap: 0.8rem; - width: 100%; -} - -.google-mark { - display: grid; - gap: 0.08rem; - grid-template-columns: repeat(2, 0.55rem); -} - -.google-mark span { - border-radius: 0.12rem; - display: inline-block; - height: 0.55rem; - width: 0.55rem; -} - -.google-mark-blue { background: #4285f4; } -.google-mark-red { background: #ea4335; } -.google-mark-yellow { background: #fbbc05; } -.google-mark-green { background: #34a853; } - -.signup-copy { - color: var(--muted); - margin: 1.3rem 0 0; - text-align: center; -} - -.signup-copy a { - color: var(--text); - font-weight: 700; -} - -@keyframes drift { - 0%, - 100% { - transform: translate3d(0, 0, 0) rotate(0deg); - } - 25% { - transform: translate3d(10px, -14px, 0) rotate(8deg); - } - 50% { - transform: translate3d(-8px, 10px, 0) rotate(-6deg); - } - 75% { - transform: translate3d(12px, 8px, 0) rotate(5deg); - } -} - -@media (max-width: 640px) { - .card-body { - border-radius: 1.45rem; - padding: 1.2rem; - } - - .headline-block h1 { - font-size: 2rem; - } - - .divider span { - padding-inline: 0.7rem; - } -}