From a14103ad8ed433514e0c09da4e60ac9ad9502f50 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 29 Apr 2026 20:11:57 +0200 Subject: [PATCH] init --- .dev/.mk/develop.mk | 37 + .dev/.mk/help/mark.mk | 26 + .dev/.mk/help/msg.mk | 11 + .dev/.mk/migratioin.mk | 14 + .dev/.mk/system.mk | 40 + .dev/.mk/test.mk | 22 + .dev/app/Dockerfile | 39 + .dev/front/Dockerfile | 17 + .dev/nginx/Dockerfile | 5 + .dev/nginx/templates/default.conf.template | 43 + .gitignore | 12 + Makefile | 13 + README.md | 60 + app/.air.toml | 37 + app/.env.example | 32 + app/cmd/app/main.go | 27 + app/embed.go | 6 + app/go.mod | 62 + app/go.sum | 145 ++ app/internal/app/bootstrap/dev.go | 43 + app/internal/app/bootstrap/prod.go | 47 + app/internal/app/dto/qr.go | 11 + app/internal/app/dto/user.go | 12 + app/internal/app/handler/api/qr.go | 185 ++ app/internal/app/handler/api/user.go | 122 ++ app/internal/app/handler/error.go | 7 + app/internal/app/handler/web/auth.go | 30 + app/internal/app/handler/web/error.go | 14 + app/internal/app/handler/web/home.go | 27 + app/internal/app/handler/web/impressum.go | 16 + app/internal/app/handler/web/qr.go | 61 + app/internal/app/handler/web/seo.go | 15 + app/internal/app/handler/web/static.go | 23 + app/internal/app/repository/err.go | 12 + app/internal/app/repository/qr.go | 183 ++ app/internal/app/repository/user.go | 134 ++ app/internal/app/router/api.go | 24 + app/internal/app/router/init.go | 34 + app/internal/app/router/web.go | 51 + app/internal/app/service/cache/load.go | 50 + app/internal/app/service/response/response.go | 45 + app/internal/app/service/shorturl/short.go | 37 + app/internal/app/service/view/template.go | 189 ++ app/internal/app/service/view/version.go | 8 + app/internal/app/usecase/auth/cookie.go | 47 + app/internal/app/usecase/auth/err.go | 16 + app/internal/app/usecase/auth/signin.go | 36 + app/internal/app/usecase/auth/signup.go | 13 + app/internal/app/usecase/auth/token.go | 130 ++ app/internal/app/usecase/qr.go | 33 + app/internal/app/usecase/qr_generate.go | 18 + app/internal/app/usecase/redirect.go | 27 + app/internal/infr/db/pgx.go | 39 + app/internal/infr/env/init.go | 18 + app/internal/infr/qrgenerator/init.go | 11 + app/internal/infr/qrgenerator/png.go | 15 + app/internal/infr/qrgenerator/svg.go | 14 + app/internal/infr/s3/s3.go | 168 ++ app/internal/middleware/auth.go | 77 + app/internal/qrcode/entity/qr.go | 101 + app/internal/qrcode/entity/qr_test.go | 187 ++ app/internal/qrcode/entity/user.go | 124 ++ app/internal/qrcode/entity/user_test.go | 159 ++ app/internal/qrcode/error.go | 11 + app/internal/qrcode/service/auth/email.go | 16 + app/internal/qrcode/service/auth/pass.go | 20 + app/internal/qrcode/service/entity/entity.go | 8 + app/internal/qrcode/service/url/validation.go | 25 + app/internal/qrcode/vo/qrimg.go | 66 + app/internal/view/front/.env.example | 1 + app/internal/view/front/.gitignore | 26 + .../view/front/.vite/deps/_metadata.json | 8 + .../view/front/.vite/deps/package.json | 3 + app/internal/view/front/index.css | 7 + app/internal/view/front/index.ts | 2 + app/internal/view/front/package-lock.json | 1905 +++++++++++++++++ app/internal/view/front/package.json | 17 + .../view/front/public/images/icons/qr.svg | 1 + app/internal/view/front/public/robots.txt | 4 + .../view/front/src/components/auth/api.ts | 10 + .../view/front/src/components/auth/entity.ts | 21 + .../view/front/src/components/auth/index.css | 2 + .../view/front/src/components/auth/index.ts | 2 + .../front/src/components/auth/login/index.css | 28 + .../src/components/auth/login/index.tmpl | 11 + .../front/src/components/auth/login/index.ts | 58 + .../src/components/auth/signup/index.css | 21 + .../src/components/auth/signup/index.tmpl | 10 + .../front/src/components/auth/signup/index.ts | 75 + .../view/front/src/components/index.css | 2 + .../view/front/src/components/index.ts | 3 + .../view/front/src/components/qr/api.ts | 14 + .../view/front/src/components/qr/entity.ts | 14 + .../src/components/qr/event/add/index.css | 13 + .../src/components/qr/event/add/index.tmpl | 6 + .../src/components/qr/event/add/index.ts | 25 + .../components/qr/event/generate/index.css | 43 + .../components/qr/event/generate/index.tmpl | 22 + .../src/components/qr/event/generate/index.ts | 47 + .../front/src/components/qr/event/index.css | 2 + .../front/src/components/qr/event/index.ts | 2 + .../view/front/src/components/qr/index.css | 2 + .../view/front/src/components/qr/index.ts | 3 + .../view/front/src/components/qr/ui/index.css | 2 + .../view/front/src/components/qr/ui/index.ts | 1 + .../front/src/components/qr/ui/item/index.css | 54 + .../src/components/qr/ui/item/index.tmpl | 26 + .../front/src/components/qr/ui/item/index.ts | 42 + .../front/src/components/qr/ui/list/index.css | 7 + .../src/components/qr/ui/list/index.tmpl | 9 + app/internal/view/front/src/layout/base.tmpl | 14 + app/internal/view/front/src/layout/index.css | 6 + .../front/src/pages/auth/login/index.tmpl | 7 + .../front/src/pages/auth/signup/index.tmpl | 7 + .../view/front/src/pages/error/404/index.css | 13 + .../view/front/src/pages/error/404/index.tmpl | 12 + .../view/front/src/pages/error/500/index.css | 14 + .../view/front/src/pages/error/500/index.tmpl | 8 + .../front/src/pages/error/500dev/index.css | 11 + .../front/src/pages/error/500dev/index.tmpl | 2 + .../view/front/src/pages/error/index.css | 3 + .../view/front/src/pages/impressum/index.tmpl | 12 + app/internal/view/front/src/pages/index.css | 1 + app/internal/view/front/src/pages/index.tmpl | 7 + .../view/front/src/pages/qr/index.tmpl | 4 + .../view/front/src/partials/footer/index.css | 16 + .../view/front/src/partials/footer/index.tmpl | 6 + .../view/front/src/partials/footer/index.ts | 11 + .../view/front/src/partials/head/index.tmpl | 8 + .../view/front/src/partials/header/index.css | 15 + .../view/front/src/partials/header/index.tmpl | 22 + .../view/front/src/partials/header/index.ts | 0 .../view/front/src/partials/index.css | 2 + app/internal/view/front/src/partials/index.ts | 1 + app/internal/view/front/src/share/index.css | 1 + .../view/front/src/share/type/entity.ts | 10 + .../view/front/src/share/ui/btn/index.css | 46 + .../view/front/src/share/ui/btn/index.tmpl | 4 + .../view/front/src/share/ui/btn/index.ts | 9 + .../front/src/share/ui/btn/primary/index.css | 5 + .../front/src/share/ui/btn/primary/index.tmpl | 1 + .../src/share/ui/btn/secondary/index.tmpl | 0 .../view/front/src/share/ui/error/index.css | 8 + .../view/front/src/share/ui/error/index.tmpl | 2 + .../view/front/src/share/ui/error/index.ts | 37 + .../view/front/src/share/ui/index.css | 2 + .../view/front/src/share/ui/input/index.css | 27 + .../view/front/src/share/ui/input/index.tmpl | 23 + .../src/share/ui/input/password/index.tmpl | 1 + .../front/src/share/ui/input/text/index.tmpl | 1 + .../front/src/share/utils/client/index.ts | 83 + .../front/src/share/utils/client/request.ts | 85 + .../src/share/utils/client/response/file.ts | 26 + .../src/share/utils/client/response/html.ts | 23 + .../src/share/utils/client/response/index.ts | 27 + .../src/share/utils/client/response/json.ts | 44 + .../view/front/src/share/utils/navigate.ts | 25 + .../view/front/src/styles/globals.css | 97 + app/internal/view/front/src/styles/reset.css | 79 + .../view/front/src/styles/typography.css | 0 app/internal/view/front/tsconfig.json | 26 + app/internal/view/front/vite.config.ts | 48 + app/migrations/20260401202343_c_schema.sql | 9 + app/migrations/20260402070545_c_user.sql | 18 + app/migrations/20260406153310_c_qr.sql | 23 + docker-compose.override.yml.example | 67 + docker-compose.yml | 43 + 167 files changed, 7060 insertions(+) create mode 100644 .dev/.mk/develop.mk create mode 100644 .dev/.mk/help/mark.mk create mode 100644 .dev/.mk/help/msg.mk create mode 100644 .dev/.mk/migratioin.mk create mode 100644 .dev/.mk/system.mk create mode 100644 .dev/.mk/test.mk create mode 100644 .dev/app/Dockerfile create mode 100644 .dev/front/Dockerfile create mode 100644 .dev/nginx/Dockerfile create mode 100644 .dev/nginx/templates/default.conf.template create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/.air.toml create mode 100644 app/.env.example create mode 100644 app/cmd/app/main.go create mode 100644 app/embed.go create mode 100644 app/go.mod create mode 100644 app/go.sum create mode 100644 app/internal/app/bootstrap/dev.go create mode 100644 app/internal/app/bootstrap/prod.go create mode 100644 app/internal/app/dto/qr.go create mode 100644 app/internal/app/dto/user.go create mode 100644 app/internal/app/handler/api/qr.go create mode 100644 app/internal/app/handler/api/user.go create mode 100644 app/internal/app/handler/error.go create mode 100644 app/internal/app/handler/web/auth.go create mode 100644 app/internal/app/handler/web/error.go create mode 100644 app/internal/app/handler/web/home.go create mode 100644 app/internal/app/handler/web/impressum.go create mode 100644 app/internal/app/handler/web/qr.go create mode 100644 app/internal/app/handler/web/seo.go create mode 100644 app/internal/app/handler/web/static.go create mode 100644 app/internal/app/repository/err.go create mode 100644 app/internal/app/repository/qr.go create mode 100644 app/internal/app/repository/user.go create mode 100644 app/internal/app/router/api.go create mode 100644 app/internal/app/router/init.go create mode 100644 app/internal/app/router/web.go create mode 100644 app/internal/app/service/cache/load.go create mode 100644 app/internal/app/service/response/response.go create mode 100644 app/internal/app/service/shorturl/short.go create mode 100644 app/internal/app/service/view/template.go create mode 100644 app/internal/app/service/view/version.go create mode 100644 app/internal/app/usecase/auth/cookie.go create mode 100644 app/internal/app/usecase/auth/err.go create mode 100644 app/internal/app/usecase/auth/signin.go create mode 100644 app/internal/app/usecase/auth/signup.go create mode 100644 app/internal/app/usecase/auth/token.go create mode 100644 app/internal/app/usecase/qr.go create mode 100644 app/internal/app/usecase/qr_generate.go create mode 100644 app/internal/app/usecase/redirect.go create mode 100644 app/internal/infr/db/pgx.go create mode 100644 app/internal/infr/env/init.go create mode 100644 app/internal/infr/qrgenerator/init.go create mode 100644 app/internal/infr/qrgenerator/png.go create mode 100644 app/internal/infr/qrgenerator/svg.go create mode 100644 app/internal/infr/s3/s3.go create mode 100644 app/internal/middleware/auth.go create mode 100644 app/internal/qrcode/entity/qr.go create mode 100644 app/internal/qrcode/entity/qr_test.go create mode 100644 app/internal/qrcode/entity/user.go create mode 100644 app/internal/qrcode/entity/user_test.go create mode 100644 app/internal/qrcode/error.go create mode 100644 app/internal/qrcode/service/auth/email.go create mode 100644 app/internal/qrcode/service/auth/pass.go create mode 100644 app/internal/qrcode/service/entity/entity.go create mode 100644 app/internal/qrcode/service/url/validation.go create mode 100644 app/internal/qrcode/vo/qrimg.go create mode 100644 app/internal/view/front/.env.example create mode 100644 app/internal/view/front/.gitignore create mode 100644 app/internal/view/front/.vite/deps/_metadata.json create mode 100644 app/internal/view/front/.vite/deps/package.json create mode 100644 app/internal/view/front/index.css create mode 100644 app/internal/view/front/index.ts create mode 100644 app/internal/view/front/package-lock.json create mode 100644 app/internal/view/front/package.json create mode 100644 app/internal/view/front/public/images/icons/qr.svg create mode 100644 app/internal/view/front/public/robots.txt create mode 100644 app/internal/view/front/src/components/auth/api.ts create mode 100644 app/internal/view/front/src/components/auth/entity.ts create mode 100644 app/internal/view/front/src/components/auth/index.css create mode 100644 app/internal/view/front/src/components/auth/index.ts create mode 100644 app/internal/view/front/src/components/auth/login/index.css create mode 100644 app/internal/view/front/src/components/auth/login/index.tmpl create mode 100644 app/internal/view/front/src/components/auth/login/index.ts create mode 100644 app/internal/view/front/src/components/auth/signup/index.css create mode 100644 app/internal/view/front/src/components/auth/signup/index.tmpl create mode 100644 app/internal/view/front/src/components/auth/signup/index.ts create mode 100644 app/internal/view/front/src/components/index.css create mode 100644 app/internal/view/front/src/components/index.ts create mode 100644 app/internal/view/front/src/components/qr/api.ts create mode 100644 app/internal/view/front/src/components/qr/entity.ts create mode 100644 app/internal/view/front/src/components/qr/event/add/index.css create mode 100644 app/internal/view/front/src/components/qr/event/add/index.tmpl create mode 100644 app/internal/view/front/src/components/qr/event/add/index.ts create mode 100644 app/internal/view/front/src/components/qr/event/generate/index.css create mode 100644 app/internal/view/front/src/components/qr/event/generate/index.tmpl create mode 100644 app/internal/view/front/src/components/qr/event/generate/index.ts create mode 100644 app/internal/view/front/src/components/qr/event/index.css create mode 100644 app/internal/view/front/src/components/qr/event/index.ts create mode 100644 app/internal/view/front/src/components/qr/index.css create mode 100644 app/internal/view/front/src/components/qr/index.ts create mode 100644 app/internal/view/front/src/components/qr/ui/index.css create mode 100644 app/internal/view/front/src/components/qr/ui/index.ts create mode 100644 app/internal/view/front/src/components/qr/ui/item/index.css create mode 100644 app/internal/view/front/src/components/qr/ui/item/index.tmpl create mode 100644 app/internal/view/front/src/components/qr/ui/item/index.ts create mode 100644 app/internal/view/front/src/components/qr/ui/list/index.css create mode 100644 app/internal/view/front/src/components/qr/ui/list/index.tmpl create mode 100644 app/internal/view/front/src/layout/base.tmpl create mode 100644 app/internal/view/front/src/layout/index.css create mode 100644 app/internal/view/front/src/pages/auth/login/index.tmpl create mode 100644 app/internal/view/front/src/pages/auth/signup/index.tmpl create mode 100644 app/internal/view/front/src/pages/error/404/index.css create mode 100644 app/internal/view/front/src/pages/error/404/index.tmpl create mode 100644 app/internal/view/front/src/pages/error/500/index.css create mode 100644 app/internal/view/front/src/pages/error/500/index.tmpl create mode 100644 app/internal/view/front/src/pages/error/500dev/index.css create mode 100644 app/internal/view/front/src/pages/error/500dev/index.tmpl create mode 100644 app/internal/view/front/src/pages/error/index.css create mode 100644 app/internal/view/front/src/pages/impressum/index.tmpl create mode 100644 app/internal/view/front/src/pages/index.css create mode 100644 app/internal/view/front/src/pages/index.tmpl create mode 100644 app/internal/view/front/src/pages/qr/index.tmpl create mode 100644 app/internal/view/front/src/partials/footer/index.css create mode 100644 app/internal/view/front/src/partials/footer/index.tmpl create mode 100644 app/internal/view/front/src/partials/footer/index.ts create mode 100644 app/internal/view/front/src/partials/head/index.tmpl create mode 100644 app/internal/view/front/src/partials/header/index.css create mode 100644 app/internal/view/front/src/partials/header/index.tmpl create mode 100644 app/internal/view/front/src/partials/header/index.ts create mode 100644 app/internal/view/front/src/partials/index.css create mode 100644 app/internal/view/front/src/partials/index.ts create mode 100644 app/internal/view/front/src/share/index.css create mode 100644 app/internal/view/front/src/share/type/entity.ts create mode 100644 app/internal/view/front/src/share/ui/btn/index.css create mode 100644 app/internal/view/front/src/share/ui/btn/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/btn/index.ts create mode 100644 app/internal/view/front/src/share/ui/btn/primary/index.css create mode 100644 app/internal/view/front/src/share/ui/btn/primary/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/btn/secondary/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/error/index.css create mode 100644 app/internal/view/front/src/share/ui/error/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/error/index.ts create mode 100644 app/internal/view/front/src/share/ui/index.css create mode 100644 app/internal/view/front/src/share/ui/input/index.css create mode 100644 app/internal/view/front/src/share/ui/input/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/input/password/index.tmpl create mode 100644 app/internal/view/front/src/share/ui/input/text/index.tmpl create mode 100644 app/internal/view/front/src/share/utils/client/index.ts create mode 100644 app/internal/view/front/src/share/utils/client/request.ts create mode 100644 app/internal/view/front/src/share/utils/client/response/file.ts create mode 100644 app/internal/view/front/src/share/utils/client/response/html.ts create mode 100644 app/internal/view/front/src/share/utils/client/response/index.ts create mode 100644 app/internal/view/front/src/share/utils/client/response/json.ts create mode 100644 app/internal/view/front/src/share/utils/navigate.ts create mode 100644 app/internal/view/front/src/styles/globals.css create mode 100644 app/internal/view/front/src/styles/reset.css create mode 100644 app/internal/view/front/src/styles/typography.css create mode 100644 app/internal/view/front/tsconfig.json create mode 100644 app/internal/view/front/vite.config.ts create mode 100644 app/migrations/20260401202343_c_schema.sql create mode 100644 app/migrations/20260402070545_c_user.sql create mode 100644 app/migrations/20260406153310_c_qr.sql create mode 100644 docker-compose.override.yml.example create mode 100644 docker-compose.yml diff --git a/.dev/.mk/develop.mk b/.dev/.mk/develop.mk new file mode 100644 index 0000000..7563960 --- /dev/null +++ b/.dev/.mk/develop.mk @@ -0,0 +1,37 @@ +##@ Develop: +.PHONY: app +app: ## start shell in qrcode + docker compose exec qrcode-app sh + +app-root: ## start shell in qrcode + docker compose exec -u root qrcode-app sh + +app-run: ## start shell in qrcode + docker compose run qrcode-app sh + +front: ## start frontend in ssr + docker compose exec qrcode-front sh + +front-run: ## start frontend in ssr + docker compose run qrcode-front sh + +sql: ## start shell in qrcode + docker compose exec qrcode-postgres bash + +app-tidy: + docker compose exec qrcode-app sh -c "go mod tidy" + +app-vendor: + docker compose exec qrcode-app sh -c "go mod vendor" + +db-exec: ## start db + docker compose exec qrcode-postgres bash + +web: + docker compose exec qrcode-web sh + +swager: + docker compose exec qrcode-app sh -c "swag init -g ./cmd/server.go -o ./docs --parseInternal --parseDependency" + +minio: + docker compose exec qrcode-minio sh diff --git a/.dev/.mk/help/mark.mk b/.dev/.mk/help/mark.mk new file mode 100644 index 0000000..575589b --- /dev/null +++ b/.dev/.mk/help/mark.mk @@ -0,0 +1,26 @@ +# Color font + = \033[30m + = \033[31m + = \033[32m + = \033[33m + = \033[34m + = \033[35m + = \033[36m + = \033[37m + +# Select + = \033[40m + = \033[41m + = \033[42m + = \033[43m + = \033[44m + = \033[45m + = \033[46m + = \033[47m + +# Emphasis (typography) + = \033[1m + = \033[4m + = \033[0m +
= \n + diff --git a/.dev/.mk/help/msg.mk b/.dev/.mk/help/msg.mk new file mode 100644 index 0000000..354242c --- /dev/null +++ b/.dev/.mk/help/msg.mk @@ -0,0 +1,11 @@ +.PHONY: help +help: help_message + +help_message: + @awk 'BEGIN {FS = ":.*##"; \ + printf "$()$()Usage:$()$() \ + $(
)make $()$() $()\"\"$()️\ + $(
)$(
)$()$()Available commands:$()$() \ + $(
)"}/^[a-zA-Z_-]+:.*?##/{ printf " $()%-10s$() %s$(
)", $$1, $$2 } /^##@/{ printf "\ + $(
)$()%s$()$(
)", substr($$0, 5) }'$(MAKEFILE_LIST) + diff --git a/.dev/.mk/migratioin.mk b/.dev/.mk/migratioin.mk new file mode 100644 index 0000000..fc8fcdf --- /dev/null +++ b/.dev/.mk/migratioin.mk @@ -0,0 +1,14 @@ +migrate-up: ## Migrate seo db to the most recent version available + docker compose exec qrcode-app sh -c 'goose -dir ./migrations postgres "host=qrcode-postgres port=5432 user=admin password=admin dbname=qrcode sslmode=disable" up' + +migrate-down: + docker compose exec qrcode-app sh -c 'goose -dir ./migrations postgres "host=qrcode-postgres port=5432 user=admin password=admin dbname=qrcode sslmode=disable" down' + +migrate-reset: + docker compose exec qrcode-app sh -c 'goose -dir ./migrations postgres "host=qrcode-postgres port=5432 user=admin password=admin dbname=qrcode sslmode=disable" reset' + +migrate-status: + docker compose exec qrcode-app sh -c 'goose -dir ./migrations postgres "host=qrcode-postgres port=5432 user=admin password=admin dbname=qrcode sslmode=disable" status' + +migrate-create: + docker compose exec qrcode-app sh -c 'goose -dir ./migrations create ${ARGS} sql' diff --git a/.dev/.mk/system.mk b/.dev/.mk/system.mk new file mode 100644 index 0000000..da68053 --- /dev/null +++ b/.dev/.mk/system.mk @@ -0,0 +1,40 @@ +##@ System: + +build: ## build project + docker compose build + +build-no-cache: ## build project + docker compose build --no-cache + +install: ## install the project + docker compose up -d + echo "waiting db up..." + sleep 5 + echo "migrate db" + docker compose exec qrcode-app sh -c 'goose -dir ./migrations postgres "host=qrcode-postgres port=5432 user=admin password=admin dbname=qrcode sslmode=disable" up' + echo "install go requarements" + docker compose exec qrcode-app sh -c "go mod tidy" + docker compose exec qrcode-app sh -c "go mod vendor" + echo "install nodejs requarements" + docker compose run qrcode-front sh -c 'npm install' + docker compose stop + echo "start the project" + docker compose up --remove-orphans + +up: ## start project + docker compose up -d --remove-orphans + +stop: ## stop project + docker compose stop + +down: ## stop project (remove db. Need to make migrate-up) + docker compose down + +watch: ## watch project + docker compose up --remove-orphans + +state: ## show state + docker compose ps + +logs: ## show last 100 lines of logs + docker compose logs --tail=100 $(ARGS) diff --git a/.dev/.mk/test.mk b/.dev/.mk/test.mk new file mode 100644 index 0000000..4aba4a2 --- /dev/null +++ b/.dev/.mk/test.mk @@ -0,0 +1,22 @@ +##@ Test + +DOCKER_COMPOSE := docker compose +APP_SERVICE := qrcode-app +GO_TEST := $(DOCKER_COMPOSE) exec $(APP_SERVICE) sh -c +COVERAGE_FILE := ./tmp/coverage.out +EXCLUDE := /internal/view|/vendor|/migrations|/tmp|/build + +.PHONY: test race cover + +test: ## Run tests excluding some directories + $(GO_TEST) 'go test -v -count=1 $$(go list ./... | grep -vE "$(EXCLUDE)") | grep -v "\[no test files\]"' + +race: ## Run tests with race detector + $(GO_TEST) 'go test -v -race -count=1 ./...' + +cover: ## Run tests with coverage and save HTML report + $(GO_TEST) 'mkdir -p ./tmp && \ + go test -v -short -coverprofile=$(COVERAGE_FILE) ./... && \ + go tool cover -html=$(COVERAGE_FILE) -o ./tmp/coverage.html && \ + rm -f $(COVERAGE_FILE)' + diff --git a/.dev/app/Dockerfile b/.dev/app/Dockerfile new file mode 100644 index 0000000..75eb9f3 --- /dev/null +++ b/.dev/app/Dockerfile @@ -0,0 +1,39 @@ +FROM golang:1.26.1-alpine3.23 + +RUN apk update && apk upgrade && apk add --no-cache curl libwebp-dev alpine-sdk vips-dev git + +ARG UID=1000 +ARG GID=1000 +ARG HOME=/home/app +ARG WORKDIR=/app + +ENV USR=app +ENV GRP=app +ENV GOPATH=${HOME}/bin +ENV PATH=$PATH:$GOPATH:$GOPATH/bin + +RUN addgroup --gid "${GID}" "${GRP}" +RUN adduser \ + -S \ + -u "${UID}" \ + -g "${GID}" \ + -D "${GRP}" \ + -h /home/app \ + "${USR}" +RUN mkdir ${WORKDIR} +RUN chown -R ${USR}:${GRP} ${WORKDIR} + +USER ${USR} + +WORKDIR ${WORKDIR} + +RUN go install github.com/air-verse/air@v1.64.5 + +RUN go install github.com/swaggo/swag/cmd/swag@v1.16.6 + +RUN go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 + +RUN export CGO_ENABLED=1 + +# RUN go install github.com/stripe/stripe-cli@latest +ENTRYPOINT air diff --git a/.dev/front/Dockerfile b/.dev/front/Dockerfile new file mode 100644 index 0000000..7e7587f --- /dev/null +++ b/.dev/front/Dockerfile @@ -0,0 +1,17 @@ +FROM node:lts-alpine3.23 + +RUN apk update && apk upgrade + + +ARG WORKDIR=/app +ARG APPDIR=${WORKDIR} + + +RUN mkdir -p ${APPDIR} +RUN chown -R node:node ${WORKDIR} + +USER node +WORKDIR ${APPDIR} + +# RUN npm run build +CMD [ "npm", "run", "dev" ] diff --git a/.dev/nginx/Dockerfile b/.dev/nginx/Dockerfile new file mode 100644 index 0000000..ea27bc6 --- /dev/null +++ b/.dev/nginx/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:1.23-alpine + +RUN apk update && apk upgrade && apk add --no-cache openssl + +CMD ["nginx", "-g", "daemon off;"] diff --git a/.dev/nginx/templates/default.conf.template b/.dev/nginx/templates/default.conf.template new file mode 100644 index 0000000..46b8b86 --- /dev/null +++ b/.dev/nginx/templates/default.conf.template @@ -0,0 +1,43 @@ +server { + listen 80; + + root /app; + client_max_body_size 400M; + + server_name _; + + location /tmp/ { + autoindex on; + autoindex_exact_size off; + autoindex_format html; + autoindex_localtime on; + } + + location / { + proxy_pass http://qrcode-app:3000; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires 0; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /public { + proxy_pass http://qrcode-front:3000; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires 0; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1362d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# app +app/.env +app/build +app/tmp +app/vendor + +# env +docker-compose.override.yml + +#other +tmp +!trash diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..23bdc53 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +SHELL := /bin/bash +MAKEFLAGS += --silent +ARGS = $(filter-out $@,$(MAKECMDGOALS)) + +.default: help + +export UID=$(shell id -u) +export GID=$(shell id -g) +export COMPOSE_PROJECT_NAME=qrcode + +DEV_DIR=.dev +include ${DEV_DIR}/.mk/*/*.mk +include ${DEV_DIR}/.mk/*.mk diff --git a/README.md b/README.md new file mode 100644 index 0000000..871940a --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# QR Code Generator + +A simple QR code generator. + +## Features + +- Generate QR codes from URLs +- Download as PNG or SVG +- Track how many times each QR code was scanned + +## Requirements + +- Docker +- Docker Compose +- Make (optional, but recommended) +- Linux (tested environment) + +## Installation + +``` bash +make install +``` + +## Usage + +Start the project: + +``` bash +make watch +``` +or +``` bash +make up +``` + +Show all available commands: + +``` bash +make help +``` + +## macOS / Windows + +The project was tested on Linux. + +If the Makefile does not work on your system, you can run the commands +manually. + +All installation steps are located in: + +``` bash +./.dev/.mk/system.mk +``` + +You can open this file and execute the commands step by step. + +## Notes + +- Make sure Docker and Docker Compose are installed and running +- On non-Linux systems, some commands may require manual adjustment diff --git a/app/.air.toml b/app/.air.toml new file mode 100644 index 0000000..001c6f1 --- /dev/null +++ b/app/.air.toml @@ -0,0 +1,37 @@ + +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + bin = "./build/app" + cmd = "go build -v -o ./build/app ./cmd/app/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..6fbfb49 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,32 @@ +# Server settings: +GIN_MODE=debug +APP_ENV=dev +APP_VERSION=0.0.1 +HOST=http://127.0.0.1 + + +# Database settings: +DB_HOST=qrcode-postgres +DB_PORT=5432 +DB_NAME=qrcode +DB_USER=admin +DB_PASSWORD=admin +DB_MIGRATIONS_TABLE=schema_migrations_qrcode +POSTGRES_DB=qrcode +POSTGRES_USER=admin +POSTGRES_PASSWORD=admin + +# JWT settings: +JWT_SECRET=secret +JWT_EXPIRE=24 + +CORS_ALLOW_ORIGINS=* + +# S3 +S3_ENDPOINT=qrcode-minio:9000 +S3_ACCESS_KEY_ID=adminminio +S3_SECRET_ACCESS_KEY=adminminio +S3_USE_SSL=false +S3_BUCKET=qrcode +S3_REGION=eu-central-1 +S3_HOST=http://127.0.0.1:9000 diff --git a/app/cmd/app/main.go b/app/cmd/app/main.go new file mode 100644 index 0000000..c4a0189 --- /dev/null +++ b/app/cmd/app/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "git.heldm.com/held/qrcode/internal/app/bootstrap" + "git.heldm.com/held/qrcode/internal/infr/env" + "github.com/gin-gonic/gin" + + "log" + "os" +) + +func main() { + var app *gin.Engine + + env.SetupEnvFile() + + if os.Getenv("APP_ENV") == "dev" { + app = bootstrap.NewAppDev() + } else { + app = bootstrap.NewApp() + } + + if err := app.Run(":3000"); err != nil { + log.Fatal(err) + } + +} diff --git a/app/embed.go b/app/embed.go new file mode 100644 index 0000000..10d3afb --- /dev/null +++ b/app/embed.go @@ -0,0 +1,6 @@ +package static + +import "embed" + +//go:embed internal/view/front/src/* internal/view/front/public +var TemplatesFS embed.FS diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..0e198b3 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,62 @@ +module git.heldm.com/held/qrcode + +go 1.26.1 + +require ( + github.com/flosch/pongo2/v6 v6.0.0 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.0.100 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/wamuir/svg-qr-code v0.0.0-20210725140500-9525ec975db7 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // 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/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..12b004a --- /dev/null +++ b/app/go.sum @@ -0,0 +1,145 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= +github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-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/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wamuir/svg-qr-code v0.0.0-20210725140500-9525ec975db7 h1:hpwKjWkjcZlKE+hzD1bGKqVu5QMUkd+Q47WogtmJtnQ= +github.com/wamuir/svg-qr-code v0.0.0-20210725140500-9525ec975db7/go.mod h1:Ln9b+pO1ObOm82e3uBNx5r3SSO/U7mnZsFdGnX3vIog= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/internal/app/bootstrap/dev.go b/app/internal/app/bootstrap/dev.go new file mode 100644 index 0000000..0702845 --- /dev/null +++ b/app/internal/app/bootstrap/dev.go @@ -0,0 +1,43 @@ +package bootstrap + +import ( + "os" + "time" + + "git.heldm.com/held/qrcode/internal/app/router" + "git.heldm.com/held/qrcode/internal/infr/db" + "git.heldm.com/held/qrcode/internal/infr/s3" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// NewAppDev is a constructor for gin.Engine +func NewAppDev() *gin.Engine { + + println("Starting app in dev mode") + + println("Setting up database") + if err := db.Connect(); err != nil { + println("Error can't connect to database ", err.Error()) + os.Exit(1) + } + + if err := s3.Init(); err != nil { + println("Error can't init s3 ", err.Error()) + os.Exit(1) + } + + println("Setting up router") + app := gin.New() + app.Use(gin.Logger()) + app.Use(cors.New(cors.Config{ + AllowOrigins: []string{os.Getenv("CORS_ALLOW_ORIGINS")}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowCredentials: true, + AllowWildcard: true, + MaxAge: 12 * time.Hour, + })) + router.InitRouterDev(app) + return app +} diff --git a/app/internal/app/bootstrap/prod.go b/app/internal/app/bootstrap/prod.go new file mode 100644 index 0000000..1dd0cf3 --- /dev/null +++ b/app/internal/app/bootstrap/prod.go @@ -0,0 +1,47 @@ +package bootstrap + +import ( + "os" + "strings" + "time" + + "git.heldm.com/held/qrcode/internal/app/router" + "git.heldm.com/held/qrcode/internal/infr/db" + "git.heldm.com/held/qrcode/internal/infr/s3" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func NewApp() *gin.Engine { + + if err := db.Connect(); err != nil { + println("Error can't connect to database ", err.Error()) + os.Exit(1) + } + + if err := s3.Init(); err != nil { + println("Error can't init s3 ", err.Error()) + os.Exit(1) + } + + corsURLs := os.Getenv("CORS_ALLOW_ORIGINS") + if corsURLs == "" { + os.Exit(1) + } + allowedOrigins := strings.Split(corsURLs, ",") + + app := gin.New() + app.Use(gin.Logger()) + app.SetTrustedProxies(nil) + app.Use(cors.New(cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowCredentials: true, + AllowWildcard: true, + MaxAge: 12 * time.Hour, + })) + router.InitRouter(app) + + return app +} diff --git a/app/internal/app/dto/qr.go b/app/internal/app/dto/qr.go new file mode 100644 index 0000000..b59bf3f --- /dev/null +++ b/app/internal/app/dto/qr.go @@ -0,0 +1,11 @@ +package dto + +type QRGenerateRequest struct { + URL string `json:"url" binding:"required"` + ImgFormat string `json:"img_format"` + ImgSize int `json:"img_size"` +} + +type QRCreateRequest struct { + URL string `json:"url" binding:"required"` +} diff --git a/app/internal/app/dto/user.go b/app/internal/app/dto/user.go new file mode 100644 index 0000000..c21453e --- /dev/null +++ b/app/internal/app/dto/user.go @@ -0,0 +1,12 @@ +package dto + +type UserSignUpRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} + +type UserLogInRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=73"` +} diff --git a/app/internal/app/handler/api/qr.go b/app/internal/app/handler/api/qr.go new file mode 100644 index 0000000..4950392 --- /dev/null +++ b/app/internal/app/handler/api/qr.go @@ -0,0 +1,185 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + + "git.heldm.com/held/qrcode/internal/app/dto" + "git.heldm.com/held/qrcode/internal/app/handler" + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/app/service/response" + "git.heldm.com/held/qrcode/internal/app/usecase" + "git.heldm.com/held/qrcode/internal/infr/qrgenerator" + "git.heldm.com/held/qrcode/internal/middleware" + "git.heldm.com/held/qrcode/internal/qrcode" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "git.heldm.com/held/qrcode/internal/qrcode/vo" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type QRHandler struct{} + +func (h QRHandler) Init(group *gin.RouterGroup) { + group.POST("generate", h.generate) + authGroup := group.Group("", middleware.UserAccessMiddleware()) + authGroup.POST("", h.create) + authGroup.DELETE("/:id", h.delete) +} + +func (h QRHandler) create(c *gin.Context) { + // get qrRequest from body + var qrRequest dto.QRCreateRequest + if err := c.ShouldBindJSON(&qrRequest); err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + userVal, exist := c.Get("user") + if !exist { + response.JSON(response.Response{ + Status: http.StatusUnauthorized, + Msg: handler.ErrUnauthorized, + Data: nil, + }, c) + return + } + user, ok := userVal.(*entity.User) + if !ok { + response.JSON(response.Response{ + Status: http.StatusUnauthorized, + Msg: fmt.Sprintf("%s. Invalid user type", handler.ErrUnauthorized), + Data: nil, + }, c) + return + } + + _, err := usecase.CreateQR(c.Request.Context(), qrRequest.URL, user.ID()) + if err != nil { + switch { + case errors.Is(err, qrcode.ErrInvalidURL): + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: qrcode.ErrInvalidURL.Error(), + Data: err.Error(), + }, c) + default: + response.JSON(response.Response{ + Status: http.StatusInternalServerError, + Msg: handler.ErrInternalError, + Data: err.Error(), + }, c) + } + return + } + + response.JSON(response.Response{ + Status: http.StatusOK, + Msg: "qr code created successfully", + Data: nil, + }, c) +} + +func (h QRHandler) generate(c *gin.Context) { + // get qrRequest from body + var qrRequest dto.QRGenerateRequest + if err := c.ShouldBindJSON(&qrRequest); err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + // create qr value object + var qr, err = vo.NewQR(qrRequest.URL, qrRequest.ImgFormat, qrRequest.ImgSize) + if err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + // generate qr code + qrBytes, err := qrgenerator.CreateQR(*qr) + if err != nil { + c.JSON( + http.StatusInternalServerError, + gin.H{"error": fmt.Sprintf("failed to generate qr code: %v", err)}, + ) + return + } + + var contentType string + switch qr.ImgFormat() { + case "svg": + contentType = "image/svg+xml" + default: + contentType = "image/png" + } + + c.Header("Content-Disposition", "attachment; filename=qr."+qr.ImgFormat()) + c.Header("Content-Type", contentType) + c.Data(http.StatusOK, contentType, qrBytes) +} + +func (h QRHandler) delete(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: fmt.Sprintf("%s. Invalid QR code ID", handler.ErrInvalidRequest), + Data: err.Error(), + }, c) + return + } + + userVal, exist := c.Get("user") + if !exist { + response.JSON(response.Response{ + Status: http.StatusUnauthorized, + Msg: handler.ErrUnauthorized, + Data: nil, + }, c) + return + } + user, ok := userVal.(*entity.User) + if !ok { + response.JSON(response.Response{ + Status: http.StatusUnauthorized, + Msg: fmt.Sprintf("%s. Invalid user type", handler.ErrUnauthorized), + Data: nil, + }, c) + return + } + + if err := repository.QR().Delete(c.Request.Context(), id, user.ID()); err != nil { + switch { + case err == repository.ErrNotFound: + response.JSON(response.Response{ + Status: http.StatusNotFound, + Msg: fmt.Sprintf("%s. QR code not found", repository.ErrNotFound), + Data: err, + }, c) + default: + response.JSON(response.Response{ + Status: http.StatusInternalServerError, + Msg: "failed to delete QR code", + Data: err.Error(), + }, c) + } + return + } + + response.JSON(response.Response{ + Status: http.StatusOK, + Msg: "QR code deleted successfully", + Data: nil, + }, c) +} diff --git a/app/internal/app/handler/api/user.go b/app/internal/app/handler/api/user.go new file mode 100644 index 0000000..52237c4 --- /dev/null +++ b/app/internal/app/handler/api/user.go @@ -0,0 +1,122 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + + "git.heldm.com/held/qrcode/internal/app/dto" + "git.heldm.com/held/qrcode/internal/app/handler" + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/app/service/response" + "git.heldm.com/held/qrcode/internal/app/usecase/auth" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct{} + +func (h AuthHandler) Init(r *gin.RouterGroup) { + r.POST("/signup", h.signUp) + r.POST("/login", h.logIn) +} + +func (h AuthHandler) signUp(c *gin.Context) { + + var signUpRequest dto.UserSignUpRequest + if err := c.ShouldBindJSON(&signUpRequest); err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + + newUser, err := entity.NewUser(signUpRequest.Name, signUpRequest.Email, signUpRequest.Password) + if err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + + if err := auth.SignUp(c.Request.Context(), newUser); err != nil { + if err != nil { + switch { + case errors.Is(err, repository.ErrEmailAlredyExists): + response.JSON(response.Response{ + Status: http.StatusConflict, + Msg: repository.ErrEmailAlredyExists.Error(), + Data: err.Error(), + }, c) + default: + response.JSON(response.Response{ + Status: http.StatusInternalServerError, + Msg: handler.ErrInternalError, + Data: err.Error(), + }, c) + } + return + } + } + + token, err := auth.GenerateToken(newUser) + if err != nil { + response.JSON(response.Response{ + Status: http.StatusInternalServerError, + Msg: fmt.Sprintf("%s. Failed to generate token", handler.ErrInternalError), + Data: err.Error(), + }, c) + return + } + + auth.CreateCookie(c, token) + + response.JSON(response.Response{ + Status: http.StatusCreated, + Msg: "User created successfully", + Data: nil, + }, c) +} + +func (h AuthHandler) logIn(c *gin.Context) { + var logInRequest dto.UserLogInRequest + if err := c.ShouldBindJSON(&logInRequest); err != nil { + response.JSON(response.Response{ + Status: http.StatusBadRequest, + Msg: handler.ErrInvalidRequest, + Data: err.Error(), + }, c) + return + } + + token, err := auth.LogIn(c.Request.Context(), &logInRequest) + if err != nil { + switch { + case errors.Is(err, auth.ErrInvalidCredentials): + response.JSON(response.Response{ + Status: http.StatusUnauthorized, + Msg: fmt.Sprintf("%s. Invalid email or password", handler.ErrInvalidRequest), + Data: err.Error(), + }, c) + default: + response.JSON(response.Response{ + Status: http.StatusInternalServerError, + Msg: fmt.Sprintf("%s. Failed to log in", handler.ErrInternalError), + Data: err.Error(), + }, c) + } + return + } + + auth.CreateCookie(c, token) + + response.JSON(response.Response{ + Status: http.StatusOK, + Msg: "Log in successful", + Data: nil, + }, c) +} diff --git a/app/internal/app/handler/error.go b/app/internal/app/handler/error.go new file mode 100644 index 0000000..fdf10af --- /dev/null +++ b/app/internal/app/handler/error.go @@ -0,0 +1,7 @@ +package handler + +var ( + ErrInvalidRequest = "invalid request" + ErrUnauthorized = "unauthorized" + ErrInternalError = "internal error" +) diff --git a/app/internal/app/handler/web/auth.go b/app/internal/app/handler/web/auth.go new file mode 100644 index 0000000..442d11a --- /dev/null +++ b/app/internal/app/handler/web/auth.go @@ -0,0 +1,30 @@ +package web + +import ( + "net/http" + + "git.heldm.com/held/qrcode/internal/app/service/view" + "git.heldm.com/held/qrcode/internal/app/usecase/auth" + "github.com/flosch/pongo2/v6" + "github.com/gin-gonic/gin" +) + +func SignUpPage(c *gin.Context) { + view.RespondHTML(c, "pages/auth/signup/index.tmpl", pongo2.Context{ + "title": "Sign Up - XRechnung", + "description": "Description - XRechnung", + }) +} + +func LogInPage(c *gin.Context) { + view.RespondHTML(c, "pages/auth/login/index.tmpl", pongo2.Context{ + "title": "Login - XRechnung", + "description": "Description - XRechnung", + }) +} + +func LogOutPage(c *gin.Context) { + auth.DeleteCookie(c) + // redirect to home + c.Redirect(http.StatusFound, "/") +} diff --git a/app/internal/app/handler/web/error.go b/app/internal/app/handler/web/error.go new file mode 100644 index 0000000..ccf6e6a --- /dev/null +++ b/app/internal/app/handler/web/error.go @@ -0,0 +1,14 @@ +package web + +import ( + "git.heldm.com/held/qrcode/internal/app/service/view" + "github.com/flosch/pongo2/v6" + "github.com/gin-gonic/gin" +) + +func Error404Page(c *gin.Context) { + view.Respond404Error(c, "pages/error/404/index.tmpl", pongo2.Context{ + "title": "Home - QR Code", + "description": "Description - QR Code", + }) +} diff --git a/app/internal/app/handler/web/home.go b/app/internal/app/handler/web/home.go new file mode 100644 index 0000000..8816717 --- /dev/null +++ b/app/internal/app/handler/web/home.go @@ -0,0 +1,27 @@ +package web + +import ( + "fmt" + + "git.heldm.com/held/qrcode/internal/app/service/view" + "github.com/gin-gonic/gin" + + "github.com/flosch/pongo2/v6" +) + +// HomePage handler for home page +func HomePage(c *gin.Context) { + fmt.Println("home page") + view.RespondHTML(c, "pages/index.tmpl", pongo2.Context{ + "title": "Home - XRechnung", + "description": "Description - XRechnung", + }) +} + +// Get Test page +func GetTest(c *gin.Context) { + view.RespondHTML(c, "pages/test/index.tmpl", pongo2.Context{ + "title": "Test - XRechnung", + "description": "Description - XRechnung", + }) +} diff --git a/app/internal/app/handler/web/impressum.go b/app/internal/app/handler/web/impressum.go new file mode 100644 index 0000000..5c5c014 --- /dev/null +++ b/app/internal/app/handler/web/impressum.go @@ -0,0 +1,16 @@ +package web + +import ( + "git.heldm.com/held/qrcode/internal/app/service/view" + "github.com/gin-gonic/gin" + + "github.com/flosch/pongo2/v6" +) + +// GetHome handler for home page +func ImpressumPage(c *gin.Context) { + view.RespondHTML(c, "pages/impressum/index.tmpl", pongo2.Context{ + "title": "Impressum - QR", + "description": "Description - QR", + }) +} diff --git a/app/internal/app/handler/web/qr.go b/app/internal/app/handler/web/qr.go new file mode 100644 index 0000000..f75a605 --- /dev/null +++ b/app/internal/app/handler/web/qr.go @@ -0,0 +1,61 @@ +package web + +import ( + "html" + "net/http" + + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/app/service/view" + "git.heldm.com/held/qrcode/internal/app/usecase" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "github.com/flosch/pongo2/v6" + "github.com/gin-gonic/gin" +) + +// GetListQR +func QRPage(c *gin.Context) { + + userVal, exist := c.Get("user") + if !exist { + view.Respond401Error(c) + return + } + user, ok := userVal.(*entity.User) + if !ok { + view.Respond401Error(c) + return + } + + var err error + qrs, err := usecase.GetListQR(c, user) + if err != nil { + view.Respond500Error(c, err) + return + } + + view.RespondHTML(c, "pages/qr/index.tmpl", pongo2.Context{ + "title": "QR Code - QR", + "description": "Description - QR", + "qrs": qrs, + }) +} + +// Redirect to QR URL +func RedirectToURL(c *gin.Context) { + slug := c.Param("slug") + + url, err := usecase.RedirectToURL(c.Request.Context(), slug) + if err != nil { + if err == repository.ErrNotFound { + view.Respond404Error(c, "pages/error/404/index.tmpl", pongo2.Context{ + "title": "Not Found - QR Code", + "description": "Description - QR Code", + }) + return + } + view.Respond500Error(c, err) + return + } + + c.Redirect(http.StatusFound, html.EscapeString(url)) +} diff --git a/app/internal/app/handler/web/seo.go b/app/internal/app/handler/web/seo.go new file mode 100644 index 0000000..f1e1517 --- /dev/null +++ b/app/internal/app/handler/web/seo.go @@ -0,0 +1,15 @@ +package web + +import ( + "net/http" + + cacheservice "git.heldm.com/held/qrcode/internal/app/service/cache" + + "github.com/gin-gonic/gin" +) + +// StaticRobotsTxt serves static files +func StaticRobotsTxt(c *gin.Context) { + c.Header("Content-Type", "text/plain") + c.Data(http.StatusOK, "text/plain", cacheservice.RobotsTxt) +} diff --git a/app/internal/app/handler/web/static.go b/app/internal/app/handler/web/static.go new file mode 100644 index 0000000..14c0332 --- /dev/null +++ b/app/internal/app/handler/web/static.go @@ -0,0 +1,23 @@ +package web + +import ( + "net/http" + + cacheservice "git.heldm.com/held/qrcode/internal/app/service/cache" + + "github.com/gin-gonic/gin" +) + +// StaticCSSFiles serves static files +func StaticCSSFile(c *gin.Context) { + c.Header("Content-Type", "text/css") + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Data(http.StatusOK, "text/css", cacheservice.CSSIndex) +} + +// StaticJSFiles serves static files +func StaticJSFile(c *gin.Context) { + c.Header("Content-Type", "application/javascript") + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Data(http.StatusOK, "application/javascript", cacheservice.JSIndex) +} diff --git a/app/internal/app/repository/err.go b/app/internal/app/repository/err.go new file mode 100644 index 0000000..3c6fced --- /dev/null +++ b/app/internal/app/repository/err.go @@ -0,0 +1,12 @@ +package repository + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrSQLQuery = errors.New("sql query error") + ErrScanRow = errors.New("failed to scan row") + ErrSQLRows = errors.New("rows iteration failed") + ErrEmailAlredyExists = errors.New("email alredy exists") + UniqueViolation = "23505" +) diff --git a/app/internal/app/repository/qr.go b/app/internal/app/repository/qr.go new file mode 100644 index 0000000..c59bb79 --- /dev/null +++ b/app/internal/app/repository/qr.go @@ -0,0 +1,183 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "git.heldm.com/held/qrcode/internal/infr/db" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type qrModel struct { + ID uuid.UUID + Slug string + URL string + UserID uuid.UUID + Visits int + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type qr struct{} + +func QR() *qr { + return &qr{} +} + +func (u *qr) Create(c context.Context, qr *entity.QR) error { + query := ` + INSERT INTO qrcode.qrs (id, slug, url, user_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err := db.Pool.Exec( + c, + query, + qr.ID(), + qr.Slug(), + qr.URL(), + qr.UserID(), + qr.CreatedAt(), + qr.UpdatedAt(), + ) + if err != nil { + return fmt.Errorf("%w: %v", ErrSQLQuery, err) + } + + return nil +} + +func (u *qr) GetByUserID(c context.Context, userID uuid.UUID) ([]*entity.QR, error) { + query := ` + SELECT id, user_id, slug, url, visits, created_at, updated_at + FROM qrcode.qrs + WHERE user_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC + ` + + rows, err := db.Pool.Query(c, query, userID) + if err != nil { + return nil, fmt.Errorf("%w: %v %v", ErrSQLQuery, "QR GetByUserID", err) + } + defer rows.Close() + + var result []*entity.QR + + for rows.Next() { + var model qrModel + + if err := rows.Scan( + &model.ID, + &model.UserID, + &model.Slug, + &model.URL, + &model.Visits, + &model.CreatedAt, + &model.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("%w: %v %v", ErrScanRow, "QR GetByUserID", err) + } + + var qr entity.QR + qr.Restore( + model.ID, + model.UserID, + model.Slug, + model.URL, + model.Visits, + model.CreatedAt, + model.UpdatedAt, + model.DeletedAt, + ) + + result = append(result, &qr) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrSQLRows, err) + } + + return result, nil +} + +func (u *qr) Delete(c context.Context, id, userID uuid.UUID) error { + query := ` + UPDATE qrcode.qrs + SET deleted_at = $1 + WHERE id = $2 and user_id = $3 + ` + + now := time.Now() + res, err := db.Pool.Exec(c, query, now, id, userID) + if err != nil { + return fmt.Errorf("%w: %v", ErrSQLQuery, err) + } + + if res.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} + +func (u *qr) GetBySlug(c context.Context, slug string) (*entity.QR, error) { + query := ` + SELECT id, user_id, slug, url, visits, created_at, updated_at + FROM qrcode.qrs + WHERE slug = $1 AND deleted_at IS NULL + ` + + row := db.Pool.QueryRow(c, query, slug) + + var model qrModel + + if err := row.Scan( + &model.ID, + &model.UserID, + &model.Slug, + &model.URL, + &model.Visits, + &model.CreatedAt, + &model.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("%w: %v", ErrScanRow, err) + } + + var qr entity.QR + qr.Restore( + model.ID, + model.UserID, + model.Slug, + model.URL, + model.Visits, + model.CreatedAt, + model.UpdatedAt, + model.DeletedAt, + ) + + return &qr, nil +} + +func (u *qr) IncrementVisits(c context.Context, id uuid.UUID) error { + query := ` + UPDATE qrcode.qrs + SET visits = visits + 1, updated_at = $1 + WHERE id = $2 + ` + + now := time.Now() + _, err := db.Pool.Exec(c, query, now, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrSQLQuery, err) + } + + return nil +} diff --git a/app/internal/app/repository/user.go b/app/internal/app/repository/user.go new file mode 100644 index 0000000..ca227a8 --- /dev/null +++ b/app/internal/app/repository/user.go @@ -0,0 +1,134 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "git.heldm.com/held/qrcode/internal/infr/db" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type userModel struct { + ID uuid.UUID + Name string + Email string + Password string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type user struct{} + +func User() *user { + return &user{} +} + +func (u *user) Create(c context.Context, user *entity.User) error { + query := ` + INSERT INTO qrcode.users (id, name, email, password) + VALUES ($1, $2, $3, $4) + ` + + _, err := db.Pool.Exec( + c, + query, + user.ID(), + user.Name(), + user.Email(), + user.Password(), + ) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == UniqueViolation && pgErr.ConstraintName == "users_email_key" { + return ErrEmailAlredyExists + } + } + + return fmt.Errorf("%w: %v", ErrSQLQuery, err) + } + + return nil +} + +func (u *user) GetByID(c context.Context, id uuid.UUID) (*entity.User, error) { + query := ` + SELECT id, name, email, created_at, updated_at + FROM qrcode.users + WHERE id = $1 AND deleted_at IS NULL + ` + + userModel := &userModel{} + row := db.Pool.QueryRow(c, query, id) + err := row.Scan( + &userModel.ID, + &userModel.Name, + &userModel.Email, + &userModel.CreatedAt, + &userModel.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("%w: %v", ErrScanRow, err) + } + + var user entity.User + user.Restore( + userModel.ID, + userModel.Name, + userModel.Email, + userModel.Password, + userModel.CreatedAt, + userModel.UpdatedAt, + userModel.DeletedAt, + ) + + return &user, nil +} + +func (u *user) GetByEmail(c context.Context, email string) (*entity.User, error) { + query := ` + SELECT id, name, email, password, created_at, updated_at + FROM qrcode.users + WHERE email = $1 + ` + + userModel := &userModel{} + row := db.Pool.QueryRow(c, query, email) + err := row.Scan( + &userModel.ID, + &userModel.Name, + &userModel.Email, + &userModel.Password, + &userModel.CreatedAt, + &userModel.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("%w: %v", ErrScanRow, err) + } + + var user entity.User + user.Restore( + userModel.ID, + userModel.Name, + userModel.Email, + userModel.Password, + userModel.CreatedAt, + userModel.UpdatedAt, + userModel.DeletedAt, + ) + + return &user, nil +} diff --git a/app/internal/app/router/api.go b/app/internal/app/router/api.go new file mode 100644 index 0000000..62b1570 --- /dev/null +++ b/app/internal/app/router/api.go @@ -0,0 +1,24 @@ +package router + +import ( + "github.com/gin-gonic/gin" + + "git.heldm.com/held/qrcode/internal/app/handler/api" +) + +// HTTPRouter is router for http +type APIRouter struct { +} + +// NewHTTPRouter is constructor for http router +func NewAPIRouter() *APIRouter { + return &APIRouter{} +} + +// InitRouter is init for router +func (h APIRouter) InitRouter(app *gin.Engine) { + apiGroup := app.Group("/api") + + api.QRHandler{}.Init(apiGroup.Group("/qr")) + api.AuthHandler{}.Init(apiGroup.Group("/auth")) +} diff --git a/app/internal/app/router/init.go b/app/internal/app/router/init.go new file mode 100644 index 0000000..0f83263 --- /dev/null +++ b/app/internal/app/router/init.go @@ -0,0 +1,34 @@ +package router + +import ( + "github.com/gin-gonic/gin" +) + +// Router is interface for router +type Router interface { + InitRouter(app *gin.Engine) +} + +// InitRouter is init for router +func InitRouter(app *gin.Engine) { + setup( + app, + NewHTTPRouter(), + NewAPIRouter(), + ) +} + +// InitRouterDev is init for router in dev mode +func InitRouterDev(app *gin.Engine) { + setup( + app, + NewHTTPRouter(), + NewAPIRouter(), + ) +} + +func setup(app *gin.Engine, router ...Router) { + for _, r := range router { + r.InitRouter(app) + } +} diff --git a/app/internal/app/router/web.go b/app/internal/app/router/web.go new file mode 100644 index 0000000..f00852f --- /dev/null +++ b/app/internal/app/router/web.go @@ -0,0 +1,51 @@ +package router + +import ( + "git.heldm.com/held/qrcode/internal/app/handler/web" + cacheservice "git.heldm.com/held/qrcode/internal/app/service/cache" + "git.heldm.com/held/qrcode/internal/app/service/view" + "git.heldm.com/held/qrcode/internal/middleware" + "github.com/gin-gonic/gin" +) + +// HTTPRouter is router for http +type HTTPRouter struct { +} + +// InitRouter is init for http router +func (h HTTPRouter) InitRouter(app *gin.Engine) { + viewDir := "./" + cacheservice.ViewPath + "/src" + view.PreloadTemplates(viewDir) + cacheservice.LoadStaticFiles() + + static := app.Group("/public") + static.GET("/index.css", web.StaticCSSFile) + static.GET("/index.js", web.StaticJSFile) + + // Public router + all := app.Group("", middleware.AllAccessMiddleware()) + all.GET("/", web.HomePage) + all.GET("/impressum", web.ImpressumPage) + all.GET("/redirect/:slug", web.RedirectToURL) + all.GET("/test", web.GetTest) + + authGroup := all.Group("/auth") + authGroup.GET("/login", web.LogInPage) + authGroup.GET("/signup", web.SignUpPage) + authGroup.GET("/logout", web.LogOutPage) + + // User router + user := app.Group("", middleware.UserAccessMiddlewareHTML()) + user.GET("/qr", web.QRPage) + + // SEO router + app.GET("/robots.txt", web.StaticRobotsTxt) + + app.NoRoute(web.Error404Page) + +} + +// NewHTTPRouter is constructor for http router +func NewHTTPRouter() *HTTPRouter { + return &HTTPRouter{} +} diff --git a/app/internal/app/service/cache/load.go b/app/internal/app/service/cache/load.go new file mode 100644 index 0000000..062eddd --- /dev/null +++ b/app/internal/app/service/cache/load.go @@ -0,0 +1,50 @@ +package cacheservice + +import ( + "errors" + "fmt" + + static "git.heldm.com/held/qrcode" +) + +const ViewPath = "internal/view/front" +const StaticPath = ViewPath + "/public/" + +// CSSIndex is a cache for static files of index.css +var CSSIndex []byte + +// JSIndex is a cache for static files of index.js +var JSIndex []byte + +// RobotsTxt is a cache for static files of robots.txt +var RobotsTxt []byte + +// LoadStaticFiles +func LoadStaticFiles() { + var err error + CSSIndex, err = LoadFileToCache("index.css") + if err != nil { + fmt.Println("Error: ", err) + return + } + JSIndex, err = LoadFileToCache("index.js") + if err != nil { + fmt.Println("Error: ", err) + return + } + RobotsTxt, err = LoadFileToCache("robots.txt") + if err != nil { + fmt.Println("Error: ", err) + } +} + +// LoadFileToCache is read a file and return data +func LoadFileToCache(path string) ([]byte, error) { + path = StaticPath + path + + data, err := static.TemplatesFS.ReadFile(path) + if err != nil { + return nil, errors.New("Failed to load file: " + err.Error()) + } + return data, nil +} diff --git a/app/internal/app/service/response/response.go b/app/internal/app/service/response/response.go new file mode 100644 index 0000000..17749e4 --- /dev/null +++ b/app/internal/app/service/response/response.go @@ -0,0 +1,45 @@ +// Package response is a package to return response +package response + +import "github.com/gin-gonic/gin" + +// Response is a struct to return response +type Response struct { + Status int + Msg string + Data any + Meta Meta +} + +// Error is a struct to return error response +type Error struct { + Status int + Msg string + Data any +} + +// JSON is a function to return response in json format +func JSON(response Response, c *gin.Context) { + c.JSON(response.Status, gin.H{ + "status": response.Status, + "message": response.Msg, + "data": response.Data, + "meta": response.Meta, + }) +} + +// JSONError is a function to return error response +func JSONError(responseErr Error, c *gin.Context) { + c.JSON(responseErr.Status, gin.H{ + "status": responseErr.Status, + "message": responseErr.Msg, + "data": responseErr.Data, + }) +} + +// Meta is a struct to return meta data +type Meta struct { + Total int64 + Count int64 + Page int +} diff --git a/app/internal/app/service/shorturl/short.go b/app/internal/app/service/shorturl/short.go new file mode 100644 index 0000000..d72adbf --- /dev/null +++ b/app/internal/app/service/shorturl/short.go @@ -0,0 +1,37 @@ +package shorturl + +import ( + "crypto/sha256" + "math/big" + + "github.com/google/uuid" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// Takes UUID string and returns 12-char slug +func SlugFromUUID(uuid uuid.UUID) string { + sum := sha256.Sum256([]byte(uuid.String())) + + // 9 bytes = 72 bits → enough for 12 base62 chars + return base62Fixed(sum[:9], 12) +} + +func base62Fixed(data []byte, length int) string { + n := new(big.Int).SetBytes(data) + base := big.NewInt(62) + mod := new(big.Int) + + out := make([]byte, length) + + for i := length - 1; i >= 0; i-- { + if n.Sign() == 0 { + out[i] = alphabet[0] + continue + } + n.DivMod(n, base, mod) + out[i] = alphabet[mod.Int64()] + } + + return string(out) +} diff --git a/app/internal/app/service/view/template.go b/app/internal/app/service/view/template.go new file mode 100644 index 0000000..7e43bea --- /dev/null +++ b/app/internal/app/service/view/template.go @@ -0,0 +1,189 @@ +package view + +import ( + "embed" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + static "git.heldm.com/held/qrcode" + cacheservice "git.heldm.com/held/qrcode/internal/app/service/cache" + + "github.com/flosch/pongo2/v6" + "github.com/gin-gonic/gin" +) + +// Preload templates into a global cache +var templateCache = make(map[string]*pongo2.Template) + +// TemplateSet is a custom pongo2 template set +var TemplateSet *pongo2.TemplateSet + +// EmbedFSLoader is a custom pongo2 template loader for embed.FS +type EmbedFSLoader struct { + fs embed.FS + baseDir string +} + +// Abs resolves the absolute path of a template. +func (l *EmbedFSLoader) Abs(base, name string) string { + return name +} + +// Get retrieves the template content from the embedded filesystem. +func (l *EmbedFSLoader) Get(path string) (io.Reader, error) { + // Normalize path by removing leading slashes + normalizedPath := strings.TrimPrefix(path, "/") + + // Ensure the path is relative to `baseDir` + if !strings.HasPrefix(normalizedPath, l.baseDir) { + normalizedPath = filepath.Join(l.baseDir, normalizedPath) + } + + // Read the file from the embedded filesystem + data, err := l.fs.ReadFile(normalizedPath) + if err != nil { + log.Printf("Failed to read file at path: %s, error: %v", normalizedPath, err) + return nil, fmt.Errorf("failed to read template %s: %w", normalizedPath, err) + } + + return strings.NewReader(string(data)), nil +} + +// RespondHTML responds with an HTML template +func RespondHTML(c *gin.Context, templatePath string, context pongo2.Context) { + output, err := RenderTemplate(c, templatePath, context) + if err != nil { + c.String(http.StatusInternalServerError, "Error rendering template: %s", err) + return + } + + c.Header("Content-Type", "text/html") + c.String(http.StatusOK, output) +} + +// RespondError responds with an error message +func Respond500Error(c *gin.Context, err error) { + var output string + + if os.Getenv("APP_ENV") != "dev" { + output, err = RenderTemplate(c, "pages/error/500/index.tmpl", pongo2.Context{ + "title": "Error - QR Code", + "description": "Description - QR Code", + "error": err.Error(), + }) + } else { + output, err = RenderTemplate(c, "pages/error/500/index.tmpl", pongo2.Context{ + "title": "Error - QR Code", + "description": "Description - QR Code", + "error": err.Error(), + }) + } + + if err != nil { + c.String(http.StatusInternalServerError, "Error rendering template: %s", err) + return + } + + c.Header("Content-Type", "text/html") + c.String(http.StatusInternalServerError, output) +} + +// Response 401 Unauthorized +func Respond401Error(c *gin.Context) { + c.Redirect(http.StatusFound, "/") +} + +// Respond404Error responds with a 404 error message +func Respond404Error(c *gin.Context, templatePath string, context pongo2.Context) { + output, err := RenderTemplate(c, templatePath, context) + if err != nil { + c.String(http.StatusInternalServerError, "Error rendering template: %s", err) + return + } + c.Header("Content-Type", "text/html") + c.String(http.StatusNotFound, output) +} + +// RenderTemplate renders a Pongo2 template +func RenderTemplate(c *gin.Context, templatePath string, context pongo2.Context) (string, error) { + tpl, ok := templateCache[templatePath] + if !ok { + return "", fmt.Errorf("Template not found in cache: %s", templatePath) + } + + // get user form token + user, exist := c.Get("user") + if exist { + context["user"] = user + } + + output, err := tpl.Execute(context) + if err != nil { + return "", fmt.Errorf("Error rendering template: %s", err) + } + + return output, nil +} + +// Preload all .tmpl files in the directory and subdirectories +func PreloadTemplates(root string) { + + loader := &EmbedFSLoader{ + fs: static.TemplatesFS, + baseDir: root, + } + + // Set pongo2's global template loader to our custom loader + TemplateSet = pongo2.NewSet("embedded", loader) + + TemplateSet.Globals.Update(pongo2.Context{ + "appVersion": GetAppVersion, + "APP_ENV": os.Getenv("APP_ENV"), + "S3_BUCKET": os.Getenv("S3_HOST") + "/" + os.Getenv("S3_BUCKET"), + "HOST": os.Getenv("HOST"), + }) + + var processDir func(string) error + processDir = func(dir string) error { + entries, err := static.TemplatesFS.ReadDir(dir) + if err != nil { + log.Fatalf("Failed to read directory: %s, error: %v", dir, err) + } + + for _, entry := range entries { + fullPath := dir + "/" + entry.Name() + if entry.IsDir() { + // If the entry is a directory, process it recursively + if err := processDir(fullPath); err != nil { + return err + } + } else { + // If the entry is a file, add it to the list + // Check if the file has a .tmpl extension + if filepath.Ext(entry.Name()) == ".tmpl" { + relPath := fullPath[len(root)-1:] // Strip the root prefix + + tpl, err := TemplateSet.FromFile(relPath) + if err != nil { + log.Printf("Failed to preload template %s: %s", relPath, err) + continue + } + + // Store the template in the cache using the file name as the key + templateCache[relPath] = tpl + log.Printf("Preloaded template: %s", relPath) + } + } + } + return nil + } + + if err := processDir(cacheservice.ViewPath + "/src"); err != nil { + log.Fatal(err.Error()) + } +} diff --git a/app/internal/app/service/view/version.go b/app/internal/app/service/view/version.go new file mode 100644 index 0000000..c6fd656 --- /dev/null +++ b/app/internal/app/service/view/version.go @@ -0,0 +1,8 @@ +package view + +import "os" + +// GetAppVersion get version frpm env +func GetAppVersion() string { + return os.Getenv("APP_VERSION") +} diff --git a/app/internal/app/usecase/auth/cookie.go b/app/internal/app/usecase/auth/cookie.go new file mode 100644 index 0000000..09bb39e --- /dev/null +++ b/app/internal/app/usecase/auth/cookie.go @@ -0,0 +1,47 @@ +// create cookie for user +package auth + +import ( + "net/http" + "os" + + "github.com/gin-gonic/gin" +) + +func CreateCookie(c *gin.Context, token string) { + secure := true + if os.Getenv("APP_ENV") == "dev" { + secure = false + } + domain := os.Getenv("HOST") + + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie( + "jwt", + token, + 60*60*24*30, + "/", + domain, + secure, + true, + ) +} + +func DeleteCookie(c *gin.Context) { + secure := true + if os.Getenv("APP_ENV") == "dev" { + secure = false + } + domain := os.Getenv("HOST") + + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie( + "jwt", + "", + -1, + "/", + domain, + secure, + true, + ) +} diff --git a/app/internal/app/usecase/auth/err.go b/app/internal/app/usecase/auth/err.go new file mode 100644 index 0000000..42e297e --- /dev/null +++ b/app/internal/app/usecase/auth/err.go @@ -0,0 +1,16 @@ +package auth + +import "errors" + +var ( + ErrInvalidEmail = errors.New("invalid email") + ErrInvalidPassword = errors.New("invalid password") + ErrUserNotFound = errors.New("user not found") + ErrHashPassword = errors.New("hash password error: ") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrInvalidClaims = errors.New("invalid token claims") + ErrMissingUserIDClaim = errors.New("user_id claim is missing") + ErrInvalidUserIDClaim = errors.New("user_id claim is invalid") + ErrUnauthorized = errors.New("unauthorized") +) diff --git a/app/internal/app/usecase/auth/signin.go b/app/internal/app/usecase/auth/signin.go new file mode 100644 index 0000000..66e64ca --- /dev/null +++ b/app/internal/app/usecase/auth/signin.go @@ -0,0 +1,36 @@ +package auth + +import ( + "context" + + "git.heldm.com/held/qrcode/internal/app/dto" + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/qrcode/service/auth" +) + +func LogIn(c context.Context, signIn *dto.UserLogInRequest) (string, error) { + // check if email exists + user, err := repository.User().GetByEmail(c, signIn.Email) + if err != nil { + if err == repository.ErrNotFound { + return "", ErrInvalidCredentials + } + return "", err + } + if user == nil { + return "", ErrInvalidCredentials + } + + // check if password is correct + if !auth.CheckPasswordHash(signIn.Password, user.Password()) { + return "", ErrInvalidCredentials + } + + // generate token + token, err := GenerateToken(user) + if err != nil { + return "", err + } + + return token, nil +} diff --git a/app/internal/app/usecase/auth/signup.go b/app/internal/app/usecase/auth/signup.go new file mode 100644 index 0000000..dee8545 --- /dev/null +++ b/app/internal/app/usecase/auth/signup.go @@ -0,0 +1,13 @@ +package auth + +import ( + "context" + + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/qrcode/entity" +) + +func SignUp(c context.Context, user *entity.User) error { + + return repository.User().Create(c, user) +} diff --git a/app/internal/app/usecase/auth/token.go b/app/internal/app/usecase/auth/token.go new file mode 100644 index 0000000..0966c92 --- /dev/null +++ b/app/internal/app/usecase/auth/token.go @@ -0,0 +1,130 @@ +package auth + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "git.heldm.com/held/qrcode/internal/qrcode/entity" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" +) + +// GenerateToken is generate a token +func GenerateToken(user *entity.User) (string, error) { + + tokenLifespan, err := strconv.Atoi(os.Getenv("JWT_EXPIRE")) + + if err != nil { + return "", err + } + + claims := jwt.MapClaims{} + claims["authorized"] = true + claims["user_id"] = user.ID() + claims["exp"] = time.Now().Add(time.Hour * time.Duration(tokenLifespan)).Unix() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + secret := os.Getenv("JWT_SECRET") + if secret == "secret" { + println("WARNING: JWT_SECRET is default value") + } + return token.SignedString([]byte(secret)) + +} + +// ExtractToken is validate token +func ExtractToken(c *gin.Context) (*jwt.Token, error) { + tokenStr := "" + tokenStr, err := getTokenFromCookie(c) + if err != nil { + tokenStr, err = getTokenFromHeader(c) + if err != nil { + return nil, err + } + } + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return []byte(os.Getenv("JWT_SECRET")), nil + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err) + } + + if token == nil || !token.Valid { + return nil, ErrInvalidToken + } + return token, nil +} + +// getTokenFromCookie is extract token from cookie +func getTokenFromCookie(c *gin.Context) (string, error) { + token, err := c.Cookie("jwt") + if err != nil { + return "", errors.New("Authorization cookie is missing") + } + + if token != "" { + return token, nil + } + + return "", errors.New("Bearer token is missing") +} + +// ExtractToken is extract token from header +func getTokenFromHeader(c *gin.Context) (string, error) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + return "", errors.New("authorization header is missing") + } + + parts := strings.Fields(authHeader) + if len(parts) != 2 { + return "", errors.New("invalid authorization header format") + } + + if !strings.EqualFold(parts[0], "Bearer") { + return "", errors.New("authorization scheme must be Bearer") + } + + if parts[1] == "" { + return "", errors.New("bearer token is missing") + } + + return parts[1], nil +} + +func ExtractUserID(token *jwt.Token) (uuid.UUID, error) { + if token == nil || !token.Valid { + return uuid.Nil, ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return uuid.Nil, ErrInvalidClaims + } + + userIDRaw, ok := claims["user_id"] + if !ok { + return uuid.Nil, ErrMissingUserIDClaim + } + + userIDStr, ok := userIDRaw.(string) + if !ok { + return uuid.Nil, ErrInvalidUserIDClaim + } + + userID, err := uuid.Parse(strings.TrimSpace(userIDStr)) + if err != nil { + return uuid.Nil, ErrInvalidUserIDClaim + } + + return userID, nil +} diff --git a/app/internal/app/usecase/qr.go b/app/internal/app/usecase/qr.go new file mode 100644 index 0000000..1671c10 --- /dev/null +++ b/app/internal/app/usecase/qr.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/qrcode/entity" + "github.com/google/uuid" +) + +func GetListQR(c context.Context, user *entity.User) ([]*entity.QR, error) { + + qrs, err := repository.QR().GetByUserID(c, user.ID()) + if err != nil { + return nil, err + } + + return qrs, nil +} + +func CreateQR(c context.Context, linkOrigin string, userID uuid.UUID) (*entity.QR, error) { + + qr, err := entity.NewQR(linkOrigin, userID) + if err != nil { + return nil, err + } + + if err := repository.QR().Create(c, qr); err != nil { + return nil, err + } + + return qr, nil +} diff --git a/app/internal/app/usecase/qr_generate.go b/app/internal/app/usecase/qr_generate.go new file mode 100644 index 0000000..afce7fa --- /dev/null +++ b/app/internal/app/usecase/qr_generate.go @@ -0,0 +1,18 @@ +package usecase + +import ( + "context" + + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/qrcode/entity" +) + +func NewUser(c context.Context, user *entity.User) error { + + if err := repository.User().Create(c, user); err != nil { + return err + } + + return nil + +} diff --git a/app/internal/app/usecase/redirect.go b/app/internal/app/usecase/redirect.go new file mode 100644 index 0000000..21d2277 --- /dev/null +++ b/app/internal/app/usecase/redirect.go @@ -0,0 +1,27 @@ +package usecase + +import ( + "context" + "time" + + "git.heldm.com/held/qrcode/internal/app/repository" + "github.com/google/uuid" +) + +func RedirectToURL(c context.Context, slug string) (string, error) { + qr, err := repository.QR().GetBySlug(c, slug) + if err != nil { + return "", err + } + + go SaveView(qr.ID()) + + return qr.URL(), nil +} + +func SaveView(qrID uuid.UUID) error { + + c, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return repository.QR().IncrementVisits(c, qrID) +} diff --git a/app/internal/infr/db/pgx.go b/app/internal/infr/db/pgx.go new file mode 100644 index 0000000..9492f29 --- /dev/null +++ b/app/internal/infr/db/pgx.go @@ -0,0 +1,39 @@ +package db + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var Pool *pgxpool.Pool + +func Connect() error { + dsn := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=disable", + os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), + os.Getenv("DB_HOST"), + os.Getenv("DB_PORT"), + os.Getenv("DB_NAME"), + ) + + c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + pool, err := pgxpool.New(c, dsn) + if err != nil { + return err + } + + if err := pool.Ping(c); err != nil { + pool.Close() + return err + } + + Pool = pool + return nil +} diff --git a/app/internal/infr/env/init.go b/app/internal/infr/env/init.go new file mode 100644 index 0000000..e1ff1bd --- /dev/null +++ b/app/internal/infr/env/init.go @@ -0,0 +1,18 @@ +// Package env provides environment variables +package env + +import ( + "log" + + "github.com/joho/godotenv" +) + +// SetupEnvFile is a function to setup .env file +func SetupEnvFile() { + envFile := ".env" + var err error + if (godotenv.Load(envFile)); err != nil { + log.Printf("No %s file found, reading from environment", envFile) + } + +} diff --git a/app/internal/infr/qrgenerator/init.go b/app/internal/infr/qrgenerator/init.go new file mode 100644 index 0000000..26d740d --- /dev/null +++ b/app/internal/infr/qrgenerator/init.go @@ -0,0 +1,11 @@ +package qrgenerator + +import "git.heldm.com/held/qrcode/internal/qrcode/vo" + +func CreateQR(qr vo.QRImg) ([]byte, error) { + if qr.ImgFormat() == "svg" { + return GenerateSVG(qr.Link()) + } + + return GenerateQRCodePNG(qr) +} diff --git a/app/internal/infr/qrgenerator/png.go b/app/internal/infr/qrgenerator/png.go new file mode 100644 index 0000000..ba7f3a4 --- /dev/null +++ b/app/internal/infr/qrgenerator/png.go @@ -0,0 +1,15 @@ +package qrgenerator + +import ( + "git.heldm.com/held/qrcode/internal/qrcode/vo" + qrcode "github.com/skip2/go-qrcode" +) + +func GenerateQRCodePNG(qr vo.QRImg) ([]byte, error) { + png, err := qrcode.Encode(qr.Link(), qrcode.Medium, qr.ImgSize()) + if err != nil { + return nil, err + } + + return png, nil +} diff --git a/app/internal/infr/qrgenerator/svg.go b/app/internal/infr/qrgenerator/svg.go new file mode 100644 index 0000000..35c8792 --- /dev/null +++ b/app/internal/infr/qrgenerator/svg.go @@ -0,0 +1,14 @@ +package qrgenerator + +import ( + qrsvg "github.com/wamuir/svg-qr-code" +) + +func GenerateSVG(text string) ([]byte, error) { + qr, err := qrsvg.New(text) + if err != nil { + return nil, err + } + + return []byte(qr.String()), nil +} diff --git a/app/internal/infr/s3/s3.go b/app/internal/infr/s3/s3.go new file mode 100644 index 0000000..32c591a --- /dev/null +++ b/app/internal/infr/s3/s3.go @@ -0,0 +1,168 @@ +// Package s3 is a package for s3 +package s3 + +import ( + "context" + "errors" + "fmt" + "mime/multipart" + "os" + "strconv" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// Client ... +var Client *minio.Client + +// Init ... +func Init() error { + c := context.Background() + endpoint := os.Getenv("S3_ENDPOINT") + accessKeyID := os.Getenv("S3_ACCESS_KEY_ID") + secretAccessKey := os.Getenv("S3_SECRET_ACCESS_KEY") + useSSL, errUseSSL := strconv.ParseBool(os.Getenv("S3_USE_SSL")) + if errUseSSL != nil { + return errUseSSL + } + + println("S3 endpoint: ", endpoint) + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: useSSL, + }) + if err != nil { + return err + } + Client = client + + backetName := os.Getenv("S3_BUCKET") + region := os.Getenv("S3_REGION") + + println("S3 bucket: ", backetName) + if err := Client.MakeBucket(c, backetName, minio.MakeBucketOptions{Region: region}); err != nil { + println("Error can't create bucket ", err.Error()) + // Check to see if we already own this bucket (which happens if you run this twice) + exists, err := Client.BucketExists(c, backetName) + println("Bucket exists: ", exists) + if err == nil && exists { + println("We already own %s\n", backetName) + } else { + println("Error can't create bucket ", err.Error()) + return err + } + } + + println("S3 init success") + return nil +} + +// Upload ... +func Upload(file *multipart.FileHeader, fileName, path string) (string, error) { + fileOpen, errOpen := file.Open() + if errOpen != nil { + return "", errOpen + } + defer fileOpen.Close() + + src := path + fileName + + _, err := Client.PutObject( + context.Background(), + os.Getenv("S3_BUCKET"), + src, + fileOpen, + file.Size, + minio.PutObjectOptions{ + ContentType: file.Header.Get("Content-Type"), + }) + if err != nil { + return "", err + } + + return src, nil +} + +// UploadWebpFile ... +func UploadWebpFile(file *os.File, src string) error { + + fileInfo, err := file.Stat() + if err != nil { + return err + } + + fileOpen, err := os.Open(file.Name()) + if err != nil { + return err + } + + info, err := Client.PutObject( + context.Background(), + os.Getenv("S3_BUCKET"), + src, + fileOpen, + fileInfo.Size(), + minio.PutObjectOptions{ + ContentType: "image/webp", + }) + if err != nil { + fmt.Println("Error upload file: ", err.Error()) + fmt.Println("info: ", fileInfo.Size()) + return err + } + fmt.Println(info) + + return nil +} + +// CreatePathAndName name and path for file +func CreatePath(path string) (string, string) { + year, _, _ := time.Now().Date() + + nameFold := strconv.FormatInt(time.Now().UnixNano(), 10) + + path = path + "/" + strconv.FormatInt(int64(year), 10) + "/" + nameFold + "/" + + return nameFold, path + +} + +// UploadRandom is a function separate files by year and create random name +func UploadRandom(file *multipart.FileHeader, path string) (string, error) { + + year, _, _ := time.Now().Date() + + path = path + "/" + strconv.FormatInt(int64(year), 10) + "/" + + fileName := strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + file.Filename + + return Upload(file, fileName, path) +} + +// Remove ... +func Remove(src string) error { + c := context.Background() + return Client.RemoveObject(c, os.Getenv("S3_BUCKET"), src, minio.RemoveObjectOptions{}) +} + +// RemoveAll ... +func RemoveAll(srcs []string) error { + c := context.Background() + for _, src := range srcs { + if src == "" { + continue + } + if err := Client.RemoveObject(c, os.Getenv("S3_BUCKET"), src, minio.RemoveObjectOptions{}); err != nil { + return errors.New("Remove file error: " + err.Error()) + } + } + return nil +} + +// GetObject ... +func GetObject(src string) (*minio.Object, error) { + c := context.Background() + return Client.GetObject(c, os.Getenv("S3_BUCKET"), src, minio.GetObjectOptions{}) +} diff --git a/app/internal/middleware/auth.go b/app/internal/middleware/auth.go new file mode 100644 index 0000000..f105000 --- /dev/null +++ b/app/internal/middleware/auth.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "errors" + "net/http" + + "git.heldm.com/held/qrcode/internal/app/repository" + "git.heldm.com/held/qrcode/internal/app/usecase/auth" + "github.com/gin-gonic/gin" +) + +func UserAccessMiddlewareHTML() gin.HandlerFunc { + return func(c *gin.Context) { + if err := setUserToContext(c); err != nil { + c.Redirect(http.StatusFound, "/auth/login") + c.Abort() + return + } + + c.Next() + } +} + +// JwtAuthUserMiddleware is middleware for jwt auth +func UserAccessMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if err := setUserToContext(c); err != nil { + switch { + case errors.Is(err, repository.ErrScanRow): + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "status": http.StatusInternalServerError, + "msg": "internal server error", + "data": err.Error(), + }) + c.Abort() + return + default: + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "status": http.StatusUnauthorized, + "msg": "unauthorized", + "data": err.Error(), + }) + c.Abort() + return + } + } + + c.Next() + } +} + +// AllAccessMiddleware is middleware for all access +func AllAccessMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + setUserToContext(c) + c.Next() + } +} + +func setUserToContext(c *gin.Context) error { + myToken, err := auth.ExtractToken(c) + if err != nil { + return err + } + + userID, err := auth.ExtractUserID(myToken) + if err != nil { + return err + } + + user, err := repository.User().GetByID(c.Request.Context(), userID) + if err != nil { + return err + } + c.Set("user", user) + return nil +} diff --git a/app/internal/qrcode/entity/qr.go b/app/internal/qrcode/entity/qr.go new file mode 100644 index 0000000..5da8a36 --- /dev/null +++ b/app/internal/qrcode/entity/qr.go @@ -0,0 +1,101 @@ +package entity + +import ( + "strings" + "time" + + "git.heldm.com/held/qrcode/internal/app/service/shorturl" + "git.heldm.com/held/qrcode/internal/qrcode" + urlservice "git.heldm.com/held/qrcode/internal/qrcode/service/url" + "github.com/google/uuid" +) + +type QR struct { + id uuid.UUID + userID uuid.UUID + slug string + url string + visits int + createdAt time.Time + updatedAt time.Time + deletedAt *time.Time +} + +func NewQR(url string, userID uuid.UUID) (*QR, error) { + var qr QR + qr.id = uuid.New() + qr.generateLink(qr.id) + if err := qr.setURL(url); err != nil { + return nil, err + } + qr.userID = userID + qr.createdAt = time.Now() + qr.updatedAt = time.Now() + + return &qr, nil +} + +func (q *QR) ID() uuid.UUID { + return q.id +} + +func (q *QR) UserID() uuid.UUID { + return q.userID +} + +func (q *QR) generateLink(id uuid.UUID) { + q.slug = shorturl.SlugFromUUID(id) +} + +func (q *QR) Slug() string { + return q.slug +} + +func (q *QR) setURL(url string) error { + url = strings.TrimSpace(url) + if ok := urlservice.IsValidURL(url); !ok { + return qrcode.ErrInvalidURL + } + q.url = url + return nil +} + +func (q *QR) URL() string { + return q.url +} + +func (q *QR) Visits() int { + return q.visits +} + +func (q *QR) CreatedAt() time.Time { + return q.createdAt +} + +func (q *QR) UpdatedAt() time.Time { + return q.updatedAt +} + +func (q *QR) DeletedAt() *time.Time { + return q.deletedAt +} + +func (q *QR) Restore( + id uuid.UUID, + userID uuid.UUID, + slug string, + url string, + visits int, + createdAt time.Time, + updatedAt time.Time, + deletedAt *time.Time, +) { + q.id = id + q.userID = userID + q.slug = slug + q.url = url + q.visits = visits + q.createdAt = createdAt + q.updatedAt = updatedAt + q.deletedAt = deletedAt +} diff --git a/app/internal/qrcode/entity/qr_test.go b/app/internal/qrcode/entity/qr_test.go new file mode 100644 index 0000000..852474f --- /dev/null +++ b/app/internal/qrcode/entity/qr_test.go @@ -0,0 +1,187 @@ +package entity + +import ( + "errors" + "strings" + "testing" + "time" + + "git.heldm.com/held/qrcode/internal/qrcode" + "github.com/google/uuid" +) + +func TestQR_New_Success(t *testing.T) { + userID := uuid.New() + + got, err := NewQR("https://example.com", userID) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got == nil { + t.Fatal("expected qr, got nil") + } + + if got.ID() == uuid.Nil { + t.Fatal("expected id to be set") + } + + if got.UserID() != userID { + t.Fatalf("expected userID %v, got %v", userID, got.UserID()) + } + + if got.URL() != "https://example.com" { + t.Fatalf("expected url https://example.com, got %q", got.URL()) + } + + if got.Slug() == "" { + t.Fatal("expected slug to be set") + } + + if got.Visits() != 0 { + t.Fatalf("expected visits 0, got %d", got.Visits()) + } + + if got.CreatedAt().IsZero() { + t.Fatal("expected createdAt to be set") + } + + if got.UpdatedAt().IsZero() { + t.Fatal("expected updatedAt to be set") + } + + if got.DeletedAt() != nil { + t.Fatalf("expected deletedAt nil, got %v", got.DeletedAt()) + } +} + +func TestQR_New_TrimURL(t *testing.T) { + userID := uuid.New() + + got, err := NewQR(" https://example.com/path ", userID) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got.URL() != "https://example.com/path" { + t.Fatalf("expected trimmed url, got %q", got.URL()) + } +} + +func TestQR_New_InvalidURL_Empty(t *testing.T) { + userID := uuid.New() + + got, err := NewQR(" ", userID) + if got != nil { + t.Fatalf("expected nil qr, got %#v", got) + } + + if !errors.Is(err, qrcode.ErrInvalidURL) { + t.Fatalf("expected ErrInvalidURL, got %v", err) + } +} + +func TestQR_New_InvalidURL_BadScheme(t *testing.T) { + userID := uuid.New() + + got, err := NewQR("ftup://example.com", userID) + if got != nil { + t.Fatalf("expected nil qr, got %#v", got) + } + + if !errors.Is(err, qrcode.ErrInvalidURL) { + t.Fatalf("expected ErrInvalidURL, got %v", err) + } +} + +func TestQR_New_InvalidURL_NotURL(t *testing.T) { + userID := uuid.New() + + got, err := NewQR("not-a-url", userID) + if got != nil { + t.Fatalf("expected nil qr, got %#v", got) + } + + if !errors.Is(err, qrcode.ErrInvalidURL) { + t.Fatalf("expected ErrInvalidURL, got %v", err) + } +} + +func TestQR_New_SetsDifferentIDsAndSlugs(t *testing.T) { + userID := uuid.New() + + got1, err := NewQR("https://example.com/1", userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got2, err := NewQR("https://example.com/2", userID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got1.ID() == got2.ID() { + t.Fatal("expected different ids") + } + + if strings.TrimSpace(got1.Slug()) == "" || strings.TrimSpace(got2.Slug()) == "" { + t.Fatal("expected non-empty slugs") + } + + if got1.Slug() == got2.Slug() { + t.Fatal("expected different slugs") + } +} + +func TestQR_Restore(t *testing.T) { + var qr QR + + id := uuid.New() + userID := uuid.New() + createdAt := time.Now().Add(-2 * time.Hour) + updatedAt := time.Now().Add(-1 * time.Hour) + deletedAt := time.Now() + + qr.Restore( + id, + userID, + "slug123", + "https://example.com", + 7, + createdAt, + updatedAt, + &deletedAt, + ) + + if qr.ID() != id { + t.Fatalf("expected id %v, got %v", id, qr.ID()) + } + + if qr.UserID() != userID { + t.Fatalf("expected userID %v, got %v", userID, qr.UserID()) + } + + if qr.Slug() != "slug123" { + t.Fatalf("expected slug slug123, got %q", qr.Slug()) + } + + if qr.URL() != "https://example.com" { + t.Fatalf("expected url https://example.com, got %q", qr.URL()) + } + + if qr.Visits() != 7 { + t.Fatalf("expected visits 7, got %d", qr.Visits()) + } + + if !qr.CreatedAt().Equal(createdAt) { + t.Fatalf("expected createdAt %v, got %v", createdAt, qr.CreatedAt()) + } + + if !qr.UpdatedAt().Equal(updatedAt) { + t.Fatalf("expected updatedAt %v, got %v", updatedAt, qr.UpdatedAt()) + } + + if qr.DeletedAt() == nil || !qr.DeletedAt().Equal(deletedAt) { + t.Fatalf("expected deletedAt %v, got %v", deletedAt, qr.DeletedAt()) + } +} diff --git a/app/internal/qrcode/entity/user.go b/app/internal/qrcode/entity/user.go new file mode 100644 index 0000000..f6a4acc --- /dev/null +++ b/app/internal/qrcode/entity/user.go @@ -0,0 +1,124 @@ +package entity + +import ( + "fmt" + "strings" + "time" + + "git.heldm.com/held/qrcode/internal/qrcode" + "git.heldm.com/held/qrcode/internal/qrcode/service/auth" + "github.com/google/uuid" +) + +type User struct { + id uuid.UUID + name string + email string + password string + createdAt time.Time + updatedAt time.Time + deletedAt *time.Time +} + +func NewUser(name, email, password string) (*User, error) { + u := &User{ + id: uuid.New(), + createdAt: time.Now(), + updatedAt: time.Now(), + } + + if err := u.setName(name); err != nil { + return nil, err + } + + if err := u.setEmail(email); err != nil { + return nil, err + } + + if err := u.setPassword(password); err != nil { + return nil, err + } + + return u, nil +} + +func (u *User) ID() uuid.UUID { + return u.id +} + +func (u *User) setName(name string) error { + name = strings.TrimSpace(name) + if name == "" || len(name) > 255 { + return qrcode.ErrInvalidName + } + u.name = name + return nil +} + +func (u *User) Name() string { + return u.name +} + +func (u *User) setEmail(email string) error { + email = strings.TrimSpace(email) + if !auth.IsValidEmail(email) { + return qrcode.ErrInvalidEmail + } + u.email = email + return nil +} + +func (u *User) Email() string { + return u.email +} + +func (u *User) setPassword(password string) error { + password = strings.TrimSpace(password) + if password == "" || len(password) < 8 || len(password) > 72 { + return qrcode.ErrPasswordLength + } + hashedPass, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("%w: %v", qrcode.ErrHashPassword, err) + } + u.password = hashedPass + return nil +} + +func (u *User) Password() string { + return u.password +} + +func (u *User) setHashedPassword(hashedPass string) { + u.password = hashedPass +} + +func (u *User) CreatedAt() time.Time { + return u.createdAt +} + +func (u *User) UpdatedAt() time.Time { + return u.updatedAt +} + +func (u *User) DeletedAt() *time.Time { + return u.deletedAt +} + +func (u *User) Restore( + id uuid.UUID, + name string, + email string, + password string, + createdAt time.Time, + updatedAt time.Time, + deletedAt *time.Time, +) { + u.id = id + u.name = name + u.email = email + u.password = password + u.createdAt = createdAt + u.updatedAt = updatedAt + u.deletedAt = deletedAt +} diff --git a/app/internal/qrcode/entity/user_test.go b/app/internal/qrcode/entity/user_test.go new file mode 100644 index 0000000..f5846b4 --- /dev/null +++ b/app/internal/qrcode/entity/user_test.go @@ -0,0 +1,159 @@ +package entity + +import ( + "errors" + "testing" + "time" + + "git.heldm.com/held/qrcode/internal/qrcode" + "github.com/google/uuid" +) + +func TestUser_New_Success(t *testing.T) { + var u User + + _, err := NewUser("John", "john@example.com", "password123") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if u.ID() == uuid.Nil { + t.Fatal("expected id to be set") + } + + if u.Name() != "John" { + t.Fatalf("expected name John, got %q", u.Name()) + } + + if u.Email() != "john@example.com" { + t.Fatalf("expected email john@example.com, got %q", u.Email()) + } + + if u.Password() == "" { + t.Fatal("expected password to be set") + } + + if u.Password() == "password123" { + t.Fatal("expected password to be hashed, got plain password") + } +} + +func TestUser_New_TrimNameAndEmail(t *testing.T) { + var u User + + _, err := NewUser(" John ", " john@example.com ", "password123") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if u.Name() != "John" { + t.Fatalf("expected trimmed name, got %q", u.Name()) + } + + if u.Email() != "john@example.com" { + t.Fatalf("expected trimmed email, got %q", u.Email()) + } +} + +func TestUser_New_InvalidName_Empty(t *testing.T) { + + _, err := NewUser(" ", "john@example.com", "password123") + if !errors.Is(err, qrcode.ErrInvalidName) { + t.Fatalf("expected ErrInvalidName, got %v", err) + } +} + +func TestUser_New_InvalidName_TooLong(t *testing.T) { + longName := make([]byte, 256) + for i := range longName { + longName[i] = 'a' + } + + _, err := NewUser(string(longName), "john@example.com", "password123") + if !errors.Is(err, qrcode.ErrInvalidName) { + t.Fatalf("expected ErrInvalidName, got %v", err) + } +} + +func TestUser_New_InvalidEmail(t *testing.T) { + + _, err := NewUser("John", "not-an-email", "password123") + if !errors.Is(err, qrcode.ErrInvalidEmail) { + t.Fatalf("expected ErrInvalidEmail, got %v", err) + } +} + +func TestUser_New_InvalidPassword_TooShort(t *testing.T) { + + _, err := NewUser("John", "john@example.com", "1234567") + if !errors.Is(err, qrcode.ErrPasswordLength) { + t.Fatalf("expected ErrPasswordLength, got %v", err) + } +} + +func TestUser_New_InvalidPassword_Empty(t *testing.T) { + + _, err := NewUser("John", "john@example.com", " ") + if !errors.Is(err, qrcode.ErrPasswordLength) { + t.Fatalf("expected ErrPasswordLength, got %v", err) + } +} + +func TestUser_New_InvalidPassword_TooLong(t *testing.T) { + longPass := make([]byte, 73) + for i := range longPass { + longPass[i] = 'a' + } + + _, err := NewUser("John", "john@example.com", string(longPass)) + if !errors.Is(err, qrcode.ErrPasswordLength) { + t.Fatalf("expected ErrPasswordLength, got %v", err) + } +} + +func TestUser_Restore(t *testing.T) { + var u User + + id := uuid.New() + createdAt := time.Now().Add(-time.Hour) + updatedAt := time.Now() + deletedAt := time.Now().Add(time.Hour) + + u.Restore( + id, + "John", + "john@example.com", + "hashed-password", + createdAt, + updatedAt, + &deletedAt, + ) + + if u.ID() != id { + t.Fatalf("expected id %v, got %v", id, u.ID()) + } + + if u.Name() != "John" { + t.Fatalf("expected name John, got %q", u.Name()) + } + + if u.Email() != "john@example.com" { + t.Fatalf("expected email john@example.com, got %q", u.Email()) + } + + if u.Password() != "hashed-password" { + t.Fatalf("expected password hashed-password, got %q", u.Password()) + } + + if !u.CreatedAt().Equal(createdAt) { + t.Fatalf("expected createdAt %v, got %v", createdAt, u.CreatedAt()) + } + + if !u.UpdatedAt().Equal(updatedAt) { + t.Fatalf("expected updatedAt %v, got %v", updatedAt, u.UpdatedAt()) + } + + if u.DeletedAt() == nil || !u.DeletedAt().Equal(deletedAt) { + t.Fatalf("expected deletedAt %v, got %v", deletedAt, u.DeletedAt()) + } +} diff --git a/app/internal/qrcode/error.go b/app/internal/qrcode/error.go new file mode 100644 index 0000000..e7efab3 --- /dev/null +++ b/app/internal/qrcode/error.go @@ -0,0 +1,11 @@ +package qrcode + +import "errors" + +var ( + ErrInvalidURL = errors.New("invalid url") + ErrInvalidName = errors.New("invalid name") + ErrInvalidEmail = errors.New("invalid email") + ErrPasswordLength = errors.New("invalid password length: must be between 8 and 72 characters") + ErrHashPassword = errors.New("hash password error: ") +) diff --git a/app/internal/qrcode/service/auth/email.go b/app/internal/qrcode/service/auth/email.go new file mode 100644 index 0000000..cf507ff --- /dev/null +++ b/app/internal/qrcode/service/auth/email.go @@ -0,0 +1,16 @@ +package auth + +import ( + "net/mail" +) + +func IsValidEmail(email string) bool { + if email == "" || len(email) < 5 || len(email) > 254 { + return false + } + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Address != email { + return false + } + return true +} diff --git a/app/internal/qrcode/service/auth/pass.go b/app/internal/qrcode/service/auth/pass.go new file mode 100644 index 0000000..8b644f4 --- /dev/null +++ b/app/internal/qrcode/service/auth/pass.go @@ -0,0 +1,20 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// CheckPasswordHash compare password with hash +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/app/internal/qrcode/service/entity/entity.go b/app/internal/qrcode/service/entity/entity.go new file mode 100644 index 0000000..c0b231b --- /dev/null +++ b/app/internal/qrcode/service/entity/entity.go @@ -0,0 +1,8 @@ +package entity + +import "github.com/google/uuid" + +// IEntity is a default entity interface +type IEntity interface { + GetID() uuid.UUID +} diff --git a/app/internal/qrcode/service/url/validation.go b/app/internal/qrcode/service/url/validation.go new file mode 100644 index 0000000..1c906c4 --- /dev/null +++ b/app/internal/qrcode/service/url/validation.go @@ -0,0 +1,25 @@ +package url + +import ( + neturl "net/url" +) + +func IsValidURL(newURL string) bool { + if newURL == "" || len(newURL) > 2048 { + return false + } + u, err := neturl.Parse(newURL) + if err != nil { + return false + } + if u.Scheme == "" || u.Host == "" { + return false + } + switch u.Scheme { + case "http", "https", "ftp", "ftps", "mailto": + break + default: + return false + } + return true +} diff --git a/app/internal/qrcode/vo/qrimg.go b/app/internal/qrcode/vo/qrimg.go new file mode 100644 index 0000000..a798630 --- /dev/null +++ b/app/internal/qrcode/vo/qrimg.go @@ -0,0 +1,66 @@ +package vo + +import ( + "net/url" + "strings" + + "git.heldm.com/held/qrcode/internal/qrcode" +) + +type QRImg struct { + link string + img []byte + imgFormat string + imgSize int +} + +func NewQR(link, imgFormat string, imgSize int) (*QRImg, error) { + link = strings.TrimSpace(link) + imgFormat = strings.TrimSpace(strings.ToLower(imgFormat)) + + if link == "" { + return nil, qrcode.ErrInvalidURL + } + + u, err := url.ParseRequestURI(link) + if err != nil { + return nil, qrcode.ErrInvalidURL + } + + if u.Scheme != "http" && u.Scheme != "https" { + return nil, qrcode.ErrInvalidURL + } + + // imgFormat should be either "png" or "svg", default to "png" if invalid + switch imgFormat { + case "png", "svg": + default: + imgFormat = "png" + } + + // imgSize should be between 256 and 2048, default to 256 if invalid + if imgSize <= 0 { + imgSize = 256 + } + if imgSize > 2048 { + imgSize = 2048 + } + + return &QRImg{ + link: link, + imgFormat: imgFormat, + imgSize: imgSize, + }, nil +} + +func (q QRImg) Link() string { + return q.link +} + +func (q QRImg) ImgFormat() string { + return q.imgFormat +} + +func (q QRImg) ImgSize() int { + return q.imgSize +} diff --git a/app/internal/view/front/.env.example b/app/internal/view/front/.env.example new file mode 100644 index 0000000..d53a6fa --- /dev/null +++ b/app/internal/view/front/.env.example @@ -0,0 +1 @@ +VITE_API_URL="http://127.0.0.1/" diff --git a/app/internal/view/front/.gitignore b/app/internal/view/front/.gitignore new file mode 100644 index 0000000..0e4c3cc --- /dev/null +++ b/app/internal/view/front/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/app/internal/view/front/.vite/deps/_metadata.json b/app/internal/view/front/.vite/deps/_metadata.json new file mode 100644 index 0000000..b24df60 --- /dev/null +++ b/app/internal/view/front/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "3fd37e12", + "configHash": "5d677169", + "lockfileHash": "e3b0c442", + "browserHash": "ba1e7c4a", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/app/internal/view/front/.vite/deps/package.json b/app/internal/view/front/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/app/internal/view/front/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/app/internal/view/front/index.css b/app/internal/view/front/index.css new file mode 100644 index 0000000..7af49f5 --- /dev/null +++ b/app/internal/view/front/index.css @@ -0,0 +1,7 @@ +@import './src/styles/reset.css'; +@import './src/styles/globals.css'; +@import './src/layout/index.css'; +@import './src/partials/index.css'; +@import './src/pages/index.css'; +@import './src/components/index.css'; +@import './src/share/index.css'; diff --git a/app/internal/view/front/index.ts b/app/internal/view/front/index.ts new file mode 100644 index 0000000..998d770 --- /dev/null +++ b/app/internal/view/front/index.ts @@ -0,0 +1,2 @@ +import './src/partials'; +import './src/components'; diff --git a/app/internal/view/front/package-lock.json b/app/internal/view/front/package-lock.json new file mode 100644 index 0000000..f22e465 --- /dev/null +++ b/app/internal/view/front/package-lock.json @@ -0,0 +1,1905 @@ +{ + "name": "my-app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-app", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^25.5.0", + "dotenv-webpack": "^9.0.0", + "typescript": "~5.9.3", + "vite": "^8.0.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-14.3.2.tgz", + "integrity": "sha512-vwEppIphpFdvaMCaHfCEv9IgwcxMljMw2TnAQBB4VWPvzXQLTb82jwmdOKzlEVUL3gNFT4l4TPKO+Bn+sqcrVQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/dotenv-defaults": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-5.0.2.tgz", + "integrity": "sha512-y5z4NhblzwNk8XBIYVzjLcFkANK0rxbRDO6kGOfH9QrVYIGVEX52IqwSprKVsaLHM9pnNkCSxazZF/JPydDPvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv": "^14.0.0" + } + }, + "node_modules/dotenv-webpack": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-9.0.0.tgz", + "integrity": "sha512-uUtvrfEt+wXXadiZ/VvBYmLmd7KffMhsdaJLZmTTGaixFBZqD2emBwLsWKP4mdJ7bj11gxEcmH6EBFAoWwHrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv-defaults": "^5.0.2" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "webpack": "^4 || ^5" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + } + } +} diff --git a/app/internal/view/front/package.json b/app/internal/view/front/package.json new file mode 100644 index 0000000..054b9fb --- /dev/null +++ b/app/internal/view/front/package.json @@ -0,0 +1,17 @@ +{ + "name": "my-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "dotenv-webpack": "^9.0.0", + "typescript": "~5.9.3", + "vite": "^8.0.1" + } +} diff --git a/app/internal/view/front/public/images/icons/qr.svg b/app/internal/view/front/public/images/icons/qr.svg new file mode 100644 index 0000000..3ac650c --- /dev/null +++ b/app/internal/view/front/public/images/icons/qr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/internal/view/front/public/robots.txt b/app/internal/view/front/public/robots.txt new file mode 100644 index 0000000..22888cc --- /dev/null +++ b/app/internal/view/front/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Disallow: /impressum +Disallow: /qr +Disallow: /auth/recover diff --git a/app/internal/view/front/src/components/auth/api.ts b/app/internal/view/front/src/components/auth/api.ts new file mode 100644 index 0000000..1320d8d --- /dev/null +++ b/app/internal/view/front/src/components/auth/api.ts @@ -0,0 +1,10 @@ +import { Client, type APIResponse } from "../../share/utils/client"; +import type { User } from "./entity"; + +export function SignUp(user: User): Promise { + return Client.Post("auth/signup", user); +} + +export function Login(email: string, password: string): Promise { + return Client.Post("auth/login", { email, password }); +} diff --git a/app/internal/view/front/src/components/auth/entity.ts b/app/internal/view/front/src/components/auth/entity.ts new file mode 100644 index 0000000..ca3454a --- /dev/null +++ b/app/internal/view/front/src/components/auth/entity.ts @@ -0,0 +1,21 @@ +import type { Entity } from "../../share/type/entity"; + +export interface User extends Entity { + name: string + email: string + password?: string +} + +export interface UserSignUp { + name: string + email: string + password?: string + rePassword: string; +} + +export interface UserLogin { + email: string + password: string +} + + diff --git a/app/internal/view/front/src/components/auth/index.css b/app/internal/view/front/src/components/auth/index.css new file mode 100644 index 0000000..6e8fc71 --- /dev/null +++ b/app/internal/view/front/src/components/auth/index.css @@ -0,0 +1,2 @@ +@import './signup/index.css'; +@import './login/index.css'; diff --git a/app/internal/view/front/src/components/auth/index.ts b/app/internal/view/front/src/components/auth/index.ts new file mode 100644 index 0000000..082397b --- /dev/null +++ b/app/internal/view/front/src/components/auth/index.ts @@ -0,0 +1,2 @@ +import './signup' +import './login' diff --git a/app/internal/view/front/src/components/auth/login/index.css b/app/internal/view/front/src/components/auth/login/index.css new file mode 100644 index 0000000..d79bade --- /dev/null +++ b/app/internal/view/front/src/components/auth/login/index.css @@ -0,0 +1,28 @@ +#c_auth_login { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 auto; + width: 100%; +} + +#c_auth_login__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#c_auth_login__error { + color: red; + margin: 1rem auto; + width: 100%; + max-width: 400px; + text-align: center; +} + +.c_auth_login__links { + margin: 1rem auto; + width: 100%; + max-width: 400px; + text-align: center; +} diff --git a/app/internal/view/front/src/components/auth/login/index.tmpl b/app/internal/view/front/src/components/auth/login/index.tmpl new file mode 100644 index 0000000..51bc91d --- /dev/null +++ b/app/internal/view/front/src/components/auth/login/index.tmpl @@ -0,0 +1,11 @@ +
+
+ {% include "share/ui/input/text/index.tmpl" with label="Email" name="email" error="Eamil is erforderlich" %} + {% include "share/ui/input/password/index.tmpl" with label="Password" name="password" error="Password ist erforderlich" %} + {% include "share/ui/btn/primary/index.tmpl" with label="Anmelden" type="submit" %} +
+
+ +
diff --git a/app/internal/view/front/src/components/auth/login/index.ts b/app/internal/view/front/src/components/auth/login/index.ts new file mode 100644 index 0000000..30ba004 --- /dev/null +++ b/app/internal/view/front/src/components/auth/login/index.ts @@ -0,0 +1,58 @@ +import { LoadingSpin } from "../../../share/ui/btn"; +import { ErrorMessage } from "../../../share/ui/error"; +import { NavigateAfterLogin } from "../../../share/utils/navigate"; +import { Login } from "../api"; +import { type UserLogin } from "../entity"; + +function init() { + const logIn = document.querySelector("#c_auth_login") as HTMLElement; + if (!logIn) return; + const form: HTMLFormElement | null = logIn.querySelector("#c_auth_login__form") as HTMLFormElement; + if (!form) return; + const errInfo = logIn.querySelector("#c_auth_login__error") as HTMLElement; + if (!errInfo) return; + + form.addEventListener("submit", (e) => submit(e, form, errInfo), false); + +} + +async function submit(e: SubmitEvent, form: HTMLFormElement, errInfo: Element) { + e.preventDefault(); + + errInfo.textContent = ""; + const formData = new FormData(form); + const data = Object.fromEntries(formData.entries()); + const userLogin: UserLogin = { + email: data.email as string, + password: data.password as string, + } + + if (!validate(form, userLogin)) { + return + } + LoadingSpin(e.submitter as HTMLButtonElement, true); + const res = await Login(userLogin.email, userLogin.password); + LoadingSpin(e.submitter as HTMLButtonElement, false); + if (res.status === 200) { + NavigateAfterLogin(); + } + if (res.status !== 200) { + errInfo.textContent = res.message || "Login failed"; + } +} + +function validate(form: HTMLFormElement, user: UserLogin): boolean { + let isValid = true + if (!user.email || !/^\S+@\S+\.\S+$/.test(user.email)) { + ErrorMessage(form, "email"); + isValid = false + } + if (!user.password || user.password.length < 8 || user.password.length > 100) { + ErrorMessage(form, "password"); + isValid = false + } + return isValid +} + + +init(); diff --git a/app/internal/view/front/src/components/auth/signup/index.css b/app/internal/view/front/src/components/auth/signup/index.css new file mode 100644 index 0000000..3d96381 --- /dev/null +++ b/app/internal/view/front/src/components/auth/signup/index.css @@ -0,0 +1,21 @@ +#c_auth_signup { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 auto; + width: 100%; +} + +#c_auth_signup__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#c_auth_signup__error { + color: red; + margin: 1rem auto; + width: 100%; + max-width: 400px; + text-align: center; +} diff --git a/app/internal/view/front/src/components/auth/signup/index.tmpl b/app/internal/view/front/src/components/auth/signup/index.tmpl new file mode 100644 index 0000000..e54c8d0 --- /dev/null +++ b/app/internal/view/front/src/components/auth/signup/index.tmpl @@ -0,0 +1,10 @@ +
+
+ {% include "share/ui/input/text/index.tmpl" with label="Name" name="name" error="Name muss zwischen 3 und 50 Zeichen lang sein" %} + {% include "share/ui/input/text/index.tmpl" with label="Email" name="email" error="Bitte eine gültige E-Mail-Adresse eingeben" %} + {% include "share/ui/input/password/index.tmpl" with label="Password" name="password" error="Passwort muss zwischen 8 und 100 Zeichen lang sein" %} + {% include "share/ui/input/password/index.tmpl" with label="Passwort wiederholen" name="rePassword" error="Bitte Passwort wiederholen" %} + {% include "share/ui/btn/primary/index.tmpl" with label="Sign Up" type="submit" %} +
+
+
diff --git a/app/internal/view/front/src/components/auth/signup/index.ts b/app/internal/view/front/src/components/auth/signup/index.ts new file mode 100644 index 0000000..93386e3 --- /dev/null +++ b/app/internal/view/front/src/components/auth/signup/index.ts @@ -0,0 +1,75 @@ +import { LoadingSpin } from "../../../share/ui/btn"; +import { ErrorMessage } from "../../../share/ui/error"; +import { NavigateAfterLogin } from "../../../share/utils/navigate"; +import { SignUp } from "../api"; +import { type User, type UserSignUp } from "../entity"; + +function init() { + const signUp = document.querySelector("#c_auth_signup") as HTMLElement; + if (!signUp) return; + const form: HTMLFormElement | null = signUp.querySelector("#c_auth_signup__form") as HTMLFormElement; + if (!form) return; + const errInfo = signUp.querySelector("#c_auth_signup__error") as HTMLElement; + if (!errInfo) return; + + form.addEventListener("submit", (e) => submit(e, form, errInfo), false); + +} + +async function submit(e: SubmitEvent, form: HTMLFormElement, errInfo: HTMLElement) { + e.preventDefault(); + + errInfo.textContent = ""; + + const formData = new FormData(form); + const data = Object.fromEntries(formData.entries()); + const userSignUp: UserSignUp = { + email: data.email as string, + name: data.name as string, + password: data.password as string, + rePassword: data.rePassword as string, + } + + if (!validate(form, userSignUp)) { + return + } + const user: User = { + email: userSignUp.email, + name: userSignUp.name, + password: userSignUp.password, + } + LoadingSpin(e.submitter as HTMLButtonElement, true); + const res = await SignUp(user); + LoadingSpin(e.submitter as HTMLButtonElement, false); + if (res.status === 201) { + NavigateAfterLogin(); + return + } + if (res.status !== 201) { + errInfo.textContent = res.message || "Sign up failed"; + } +} + +function validate(form: HTMLFormElement, user: UserSignUp): boolean { + let isValid = true + if (!user.name || user.name.length < 3 || user.name.length > 50) { + ErrorMessage(form, "name"); + isValid = false + } + if (!user.email || !/^\S+@\S+\.\S+$/.test(user.email)) { + ErrorMessage(form, "email"); + isValid = false + } + if (!user.password || user.password.length < 8 || user.password.length > 100) { + ErrorMessage(form, "password"); + isValid = false + } + if (!user.rePassword || user.password != user.rePassword) { + ErrorMessage(form, "rePassword"); + isValid = false + } + return isValid +} + + +init(); diff --git a/app/internal/view/front/src/components/index.css b/app/internal/view/front/src/components/index.css new file mode 100644 index 0000000..e3027f3 --- /dev/null +++ b/app/internal/view/front/src/components/index.css @@ -0,0 +1,2 @@ +@import './qr/index.css'; +@import './auth/index.css'; diff --git a/app/internal/view/front/src/components/index.ts b/app/internal/view/front/src/components/index.ts new file mode 100644 index 0000000..4c04d4a --- /dev/null +++ b/app/internal/view/front/src/components/index.ts @@ -0,0 +1,3 @@ +import "./qr"; +import "./auth"; + diff --git a/app/internal/view/front/src/components/qr/api.ts b/app/internal/view/front/src/components/qr/api.ts new file mode 100644 index 0000000..0a7ee6d --- /dev/null +++ b/app/internal/view/front/src/components/qr/api.ts @@ -0,0 +1,14 @@ +import { Client, type APIResponse } from "../../share/utils/client"; +import type { QR, QRGenerate } from "./entity"; + +export function Generate(qr: QRGenerate): Promise { + return Client.Post("qr/generate", qr); +} + +export function Create(qr: QR): Promise { + return Client.Post("qr", qr); +} + +export function Deelete(id: string): Promise { + return Client.Delete("qr/" + id); +} diff --git a/app/internal/view/front/src/components/qr/entity.ts b/app/internal/view/front/src/components/qr/entity.ts new file mode 100644 index 0000000..ab7492c --- /dev/null +++ b/app/internal/view/front/src/components/qr/entity.ts @@ -0,0 +1,14 @@ +import type { Entity } from "../../share/type/entity"; + +export interface QRGenerate { + url: string; + img_format: string; + img_size?: number; +} + +export interface QR extends Entity { + slug?: string; + url: string; +} + + diff --git a/app/internal/view/front/src/components/qr/event/add/index.css b/app/internal/view/front/src/components/qr/event/add/index.css new file mode 100644 index 0000000..2c37d08 --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/add/index.css @@ -0,0 +1,13 @@ +#c_qr_e_add { + display: flex; + flex-direction: column; + justify-content: center; +} + +#c_qr_e_add__form { + display: flex; + flex-direction: column; + gap: 1rem; + width: 800px; + margin: 0 auto; +} diff --git a/app/internal/view/front/src/components/qr/event/add/index.tmpl b/app/internal/view/front/src/components/qr/event/add/index.tmpl new file mode 100644 index 0000000..2f97eb0 --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/add/index.tmpl @@ -0,0 +1,6 @@ +
+
+ {% include "share/ui/input/text/index.tmpl" with label="URL" name="url" value="https://" error="URL ist erforderlich" %} + {% include "share/ui/btn/primary/index.tmpl" with label="Create Follow QR" type="submit" %} +
+
diff --git a/app/internal/view/front/src/components/qr/event/add/index.ts b/app/internal/view/front/src/components/qr/event/add/index.ts new file mode 100644 index 0000000..cf0ad0a --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/add/index.ts @@ -0,0 +1,25 @@ +import { Create } from "../../api"; + +function add() { + const form = document.querySelector('#c_qr_e_add__form') as HTMLFormElement; + if (!form) return; + + form.addEventListener('submit', (e) => submit(e, form)); +} +add(); + +async function submit(e: Event, form: HTMLFormElement) { + e.preventDefault(); + const formData = new FormData(form); + const data = Object.fromEntries(formData.entries()); + const qr = { + url: data.url as string, + } + const res = await Create(qr) + if (res.statusText === "error") { + alert(res.error); + return; + } + + window.location.reload(); +} diff --git a/app/internal/view/front/src/components/qr/event/generate/index.css b/app/internal/view/front/src/components/qr/event/generate/index.css new file mode 100644 index 0000000..a43033e --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/generate/index.css @@ -0,0 +1,43 @@ +.c_qr_e_generate { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0 auto; + width: 100%; + max-width: 800px; +} + +.c_qr_e_generate button { + padding: 10px 16px; + border-radius: 8px; + border: 1px solid #0474cd; + + font-size: 14px; + font-weight: 600; + text-align: center; + + background: #0050ae; + color: #ffffff; + + cursor: pointer; +} + +.c_qr_e_generate button:hover { + background: #0474cd; + border-color: #0050ae; +} + +.c_qr_e_generate button:active { + transform: scale(0.99); +} + +.c_qr_e_generate button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.c_qr_e_generate .field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} diff --git a/app/internal/view/front/src/components/qr/event/generate/index.tmpl b/app/internal/view/front/src/components/qr/event/generate/index.tmpl new file mode 100644 index 0000000..a75d778 --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/generate/index.tmpl @@ -0,0 +1,22 @@ +
+ {% if qr %} + + {% else %} +
+ + +
+ {% endif %} +
+ + +
+
+ + +
+ +
diff --git a/app/internal/view/front/src/components/qr/event/generate/index.ts b/app/internal/view/front/src/components/qr/event/generate/index.ts new file mode 100644 index 0000000..2219722 --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/generate/index.ts @@ -0,0 +1,47 @@ +import { downloadFile } from "../../../../share/utils/client/response/file"; +import { Generate } from "../../api"; + +function generate() { + const forms = document.querySelectorAll('.c_qr_e_generate') as NodeListOf; + if (!forms) return; + + for (const form of forms) { + form.addEventListener('submit', (e) => submit(e, form)); + + const selectFormat = form.querySelector('select[name="format"]') as HTMLSelectElement; + const size = form.querySelector('input[name="size"]') as HTMLInputElement; + if (selectFormat && size) { + selectFormat.addEventListener('change', () => changeFormat(size, selectFormat)); + } + } +} +generate(); + +async function submit(e: Event, form: HTMLFormElement) { + e.preventDefault(); + const formData = new FormData(form); + const data = Object.fromEntries(formData.entries()); + const qr = { + url: data.url as string, + img_format: data.format as string, + img_size: data.size ? parseInt(data.size as string) : undefined, + } + const res = await Generate(qr) + if (res.statusText === "error") { + alert(res.error); + } + if (res.status === 200) { + downloadFile("qr", res.data) + } +} + +function changeFormat(size: HTMLInputElement, selectFormat: HTMLSelectElement) { + const value = selectFormat.value; + if (value === "png") { + size.disabled = false; + } else { + size.disabled = true; + size.value = ""; + } +} + diff --git a/app/internal/view/front/src/components/qr/event/index.css b/app/internal/view/front/src/components/qr/event/index.css new file mode 100644 index 0000000..461907a --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/index.css @@ -0,0 +1,2 @@ +@import './generate/index.css'; +@import './add/index.css'; diff --git a/app/internal/view/front/src/components/qr/event/index.ts b/app/internal/view/front/src/components/qr/event/index.ts new file mode 100644 index 0000000..a23347c --- /dev/null +++ b/app/internal/view/front/src/components/qr/event/index.ts @@ -0,0 +1,2 @@ +import './add' +import './generate' diff --git a/app/internal/view/front/src/components/qr/index.css b/app/internal/view/front/src/components/qr/index.css new file mode 100644 index 0000000..5e6cd58 --- /dev/null +++ b/app/internal/view/front/src/components/qr/index.css @@ -0,0 +1,2 @@ +@import './event/index.css'; +@import './ui/index.css'; diff --git a/app/internal/view/front/src/components/qr/index.ts b/app/internal/view/front/src/components/qr/index.ts new file mode 100644 index 0000000..10505de --- /dev/null +++ b/app/internal/view/front/src/components/qr/index.ts @@ -0,0 +1,3 @@ +import './event'; +import './ui'; + diff --git a/app/internal/view/front/src/components/qr/ui/index.css b/app/internal/view/front/src/components/qr/ui/index.css new file mode 100644 index 0000000..f47e9ab --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/index.css @@ -0,0 +1,2 @@ +@import './list/index.css'; +@import './item/index.css'; diff --git a/app/internal/view/front/src/components/qr/ui/index.ts b/app/internal/view/front/src/components/qr/ui/index.ts new file mode 100644 index 0000000..3c3fcc4 --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/index.ts @@ -0,0 +1 @@ +import './item' diff --git a/app/internal/view/front/src/components/qr/ui/item/index.css b/app/internal/view/front/src/components/qr/ui/item/index.css new file mode 100644 index 0000000..9d68654 --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/item/index.css @@ -0,0 +1,54 @@ +.c_qr_ui_item { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border: 1px solid #ccc; + border-radius: 4px; +} + +.c_qr_ui_item .info { + display: flex; + gap: 0.5rem; + +} + +.c_qr_ui_item .link { + width: 100%; +} + +.c_qr_ui_item .stats { + display: flex; + justify-content: space-between; + + gap: 1rem; +} + +.c_qr_ui_item .actions { + display: flex; + justify-content: space-between; + align-items: start; +} + +.c_qr_ui_item .download { + display: flex; + gap: 0.5rem; + align-items: center; + +} + +.c_qr_ui_item .download img { + width: 24px; + height: 24px; +} + +.c_qr_ui_item .generate_qr_form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + width: 100%; + background-color: #f9f9f9; + border: 1px solid #f9f9f9; + border-radius: 4px; +} diff --git a/app/internal/view/front/src/components/qr/ui/item/index.tmpl b/app/internal/view/front/src/components/qr/ui/item/index.tmpl new file mode 100644 index 0000000..11acd51 --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/item/index.tmpl @@ -0,0 +1,26 @@ +
+
+ + +
+ {% include "components/qr/event/generate/index.tmpl" with qr=qr class="hidden" %} +
+ +
+
+
Visits: {{ qr.Visits() }}
+
+
diff --git a/app/internal/view/front/src/components/qr/ui/item/index.ts b/app/internal/view/front/src/components/qr/ui/item/index.ts new file mode 100644 index 0000000..184a81c --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/item/index.ts @@ -0,0 +1,42 @@ +import { Deelete } from "../../api"; + +function deleteItemEvent() { + const items = document.querySelectorAll('.c_qr_ui_item') as NodeListOf; + if (!items) return; + for (const item of items) { + const btnDelete = item.querySelector('[data-action="remove"]') as HTMLButtonElement; + const btnGenerate = item.querySelector('[data-action="generate"]') as HTMLButtonElement; + if (btnDelete) { + btnDelete.addEventListener('click', () => deleteItem(item)); + } + if (btnGenerate) { + btnGenerate.addEventListener('click', () => openGenerateForm(item)); + } + } +} + +deleteItemEvent(); + +async function deleteItem(item: Element) { + + const id = item.getAttribute('data-id'); + if (!id) return; + + if (!confirm("Are you sure you want to delete this QR code?")) return; + + const res = await Deelete(id); + + if (res.status === 404) { + alert(res.error); + } + + if (res.status === 200) { + item.remove(); + } +} + +function openGenerateForm(item: Element) { + const form = item.querySelector('.c_qr_e_generate') as HTMLFormElement; + if (!form) return; + form.classList.toggle('hidden'); +} diff --git a/app/internal/view/front/src/components/qr/ui/list/index.css b/app/internal/view/front/src/components/qr/ui/list/index.css new file mode 100644 index 0000000..54c8ef3 --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/list/index.css @@ -0,0 +1,7 @@ +#c_qr_ui_list #list { + display: flex; + flex-direction: column; + gap: 1rem; + width: 800px; + margin: 50px auto 0 auto; +} diff --git a/app/internal/view/front/src/components/qr/ui/list/index.tmpl b/app/internal/view/front/src/components/qr/ui/list/index.tmpl new file mode 100644 index 0000000..ce10679 --- /dev/null +++ b/app/internal/view/front/src/components/qr/ui/list/index.tmpl @@ -0,0 +1,9 @@ +
+

QR Codes

+
{% include "components/qr/event/add/index.tmpl" %}
+
+ {% for qr in qrs %} + {% include "components/qr/ui/item/index.tmpl" with qr=qr %} + {% endfor %} +
+
diff --git a/app/internal/view/front/src/layout/base.tmpl b/app/internal/view/front/src/layout/base.tmpl new file mode 100644 index 0000000..aeccaa3 --- /dev/null +++ b/app/internal/view/front/src/layout/base.tmpl @@ -0,0 +1,14 @@ + + + + {% include "partials/head/index.tmpl" %} + {% block head %}{% endblock %} + + + {% include "partials/header/index.tmpl" %} +
+ {% block content %}{% endblock %} +
+ {% include "partials/footer/index.tmpl" %} + + diff --git a/app/internal/view/front/src/layout/index.css b/app/internal/view/front/src/layout/index.css new file mode 100644 index 0000000..984af2b --- /dev/null +++ b/app/internal/view/front/src/layout/index.css @@ -0,0 +1,6 @@ +#base { + margin: 0 auto; + padding: 2rem 1rem; + width: 100%; + max-width: 800px; +} diff --git a/app/internal/view/front/src/pages/auth/login/index.tmpl b/app/internal/view/front/src/pages/auth/login/index.tmpl new file mode 100644 index 0000000..a5a2db0 --- /dev/null +++ b/app/internal/view/front/src/pages/auth/login/index.tmpl @@ -0,0 +1,7 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+

Anmelden

+ {% include "components/auth/login/index.tmpl" %} +
+{% endblock %} diff --git a/app/internal/view/front/src/pages/auth/signup/index.tmpl b/app/internal/view/front/src/pages/auth/signup/index.tmpl new file mode 100644 index 0000000..a3d47ad --- /dev/null +++ b/app/internal/view/front/src/pages/auth/signup/index.tmpl @@ -0,0 +1,7 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+

Sign Up

+ {% include "components/auth/signup/index.tmpl" %} +
+{% endblock %} diff --git a/app/internal/view/front/src/pages/error/404/index.css b/app/internal/view/front/src/pages/error/404/index.css new file mode 100644 index 0000000..e91f4ec --- /dev/null +++ b/app/internal/view/front/src/pages/error/404/index.css @@ -0,0 +1,13 @@ +#p_error_404 { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0 auto; + padding: 2rem; +} + +#p_error_404 .message { + margin: 2rem 0; + font-size: 1.5rem; +} diff --git a/app/internal/view/front/src/pages/error/404/index.tmpl b/app/internal/view/front/src/pages/error/404/index.tmpl new file mode 100644 index 0000000..ffb7255 --- /dev/null +++ b/app/internal/view/front/src/pages/error/404/index.tmpl @@ -0,0 +1,12 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+ 404 Not Found + +
+{% endblock %} diff --git a/app/internal/view/front/src/pages/error/500/index.css b/app/internal/view/front/src/pages/error/500/index.css new file mode 100644 index 0000000..94bee16 --- /dev/null +++ b/app/internal/view/front/src/pages/error/500/index.css @@ -0,0 +1,14 @@ +#p_error_500 { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0 auto; + padding: 2rem; + font-weight: bold; +} + +#p_error_500 a { + margin: 20px 0; + font-size: 1.5rem; +} diff --git a/app/internal/view/front/src/pages/error/500/index.tmpl b/app/internal/view/front/src/pages/error/500/index.tmpl new file mode 100644 index 0000000..c7b44a9 --- /dev/null +++ b/app/internal/view/front/src/pages/error/500/index.tmpl @@ -0,0 +1,8 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+

500 Internal Server Error

+

Sorry, something went wrong on our end. Please try again later.

+ Go back to Home +
+{% endblock %} diff --git a/app/internal/view/front/src/pages/error/500dev/index.css b/app/internal/view/front/src/pages/error/500dev/index.css new file mode 100644 index 0000000..c8a5ea4 --- /dev/null +++ b/app/internal/view/front/src/pages/error/500dev/index.css @@ -0,0 +1,11 @@ +#p_error_500dev { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0 auto; + padding: 2rem; + color: red; + font-size: 2rem; + font-weight: bold; +} diff --git a/app/internal/view/front/src/pages/error/500dev/index.tmpl b/app/internal/view/front/src/pages/error/500dev/index.tmpl new file mode 100644 index 0000000..21e7207 --- /dev/null +++ b/app/internal/view/front/src/pages/error/500dev/index.tmpl @@ -0,0 +1,2 @@ +{% extends "layout/base.tmpl" %} +{% block content %}
{{ error }}
{% endblock %} diff --git a/app/internal/view/front/src/pages/error/index.css b/app/internal/view/front/src/pages/error/index.css new file mode 100644 index 0000000..ba5fdc4 --- /dev/null +++ b/app/internal/view/front/src/pages/error/index.css @@ -0,0 +1,3 @@ +@import './404/index.css'; +@import './500/index.css'; +@import './500dev/index.css'; diff --git a/app/internal/view/front/src/pages/impressum/index.tmpl b/app/internal/view/front/src/pages/impressum/index.tmpl new file mode 100644 index 0000000..d73d05a --- /dev/null +++ b/app/internal/view/front/src/pages/impressum/index.tmpl @@ -0,0 +1,12 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+

Impressum

+
Michael Held
+
Ortsstr. 4
+
93161 Sinzing
+
Deutschland
+
Tel.: 017664640917
+
E-Mail: michael@heldm.de
+
+{% endblock %} diff --git a/app/internal/view/front/src/pages/index.css b/app/internal/view/front/src/pages/index.css new file mode 100644 index 0000000..dbad25b --- /dev/null +++ b/app/internal/view/front/src/pages/index.css @@ -0,0 +1 @@ +@import './error/index.css'; diff --git a/app/internal/view/front/src/pages/index.tmpl b/app/internal/view/front/src/pages/index.tmpl new file mode 100644 index 0000000..73dcc25 --- /dev/null +++ b/app/internal/view/front/src/pages/index.tmpl @@ -0,0 +1,7 @@ +{% extends "layout/base.tmpl" %} +{% block content %} +
+

Generate QR

+ {% include "components/qr/event/generate/index.tmpl" %} +
+{% endblock %} diff --git a/app/internal/view/front/src/pages/qr/index.tmpl b/app/internal/view/front/src/pages/qr/index.tmpl new file mode 100644 index 0000000..adb5fbb --- /dev/null +++ b/app/internal/view/front/src/pages/qr/index.tmpl @@ -0,0 +1,4 @@ +{% extends "layout/base.tmpl" %} +{% block content %} + {% include "components/qr/ui/list/index.tmpl" %} +{% endblock %} diff --git a/app/internal/view/front/src/partials/footer/index.css b/app/internal/view/front/src/partials/footer/index.css new file mode 100644 index 0000000..90397a3 --- /dev/null +++ b/app/internal/view/front/src/partials/footer/index.css @@ -0,0 +1,16 @@ +footer { + background-color: rgb(64 64 64); + color: white; + width: 100%; + font-weight: 300; + padding: min(1rem, 5%); + display: flex; + justify-content: center; + align-items: center; + gap: 3rem; +} + +footer a { + color: white; + text-decoration: none; +} diff --git a/app/internal/view/front/src/partials/footer/index.tmpl b/app/internal/view/front/src/partials/footer/index.tmpl new file mode 100644 index 0000000..e7d13fe --- /dev/null +++ b/app/internal/view/front/src/partials/footer/index.tmpl @@ -0,0 +1,6 @@ + diff --git a/app/internal/view/front/src/partials/footer/index.ts b/app/internal/view/front/src/partials/footer/index.ts new file mode 100644 index 0000000..a7650f8 --- /dev/null +++ b/app/internal/view/front/src/partials/footer/index.ts @@ -0,0 +1,11 @@ +function FooterButton() { + const btn = document.getElementById('footer-btn') as HTMLButtonElement + if (!btn) return; + + btn.addEventListener('click', () => { + alert('Footer button clicked!') + } + ) +} + +FooterButton(); diff --git a/app/internal/view/front/src/partials/head/index.tmpl b/app/internal/view/front/src/partials/head/index.tmpl new file mode 100644 index 0000000..e249f5f --- /dev/null +++ b/app/internal/view/front/src/partials/head/index.tmpl @@ -0,0 +1,8 @@ +{{ title }} + + + + + + diff --git a/app/internal/view/front/src/partials/header/index.css b/app/internal/view/front/src/partials/header/index.css new file mode 100644 index 0000000..6ce978a --- /dev/null +++ b/app/internal/view/front/src/partials/header/index.css @@ -0,0 +1,15 @@ +header { + padding: 20px; + background-color: #f8f8f8; + width: 100%; +} + +header nav { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} diff --git a/app/internal/view/front/src/partials/header/index.tmpl b/app/internal/view/front/src/partials/header/index.tmpl new file mode 100644 index 0000000..8b7272c --- /dev/null +++ b/app/internal/view/front/src/partials/header/index.tmpl @@ -0,0 +1,22 @@ +
+ +
diff --git a/app/internal/view/front/src/partials/header/index.ts b/app/internal/view/front/src/partials/header/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/internal/view/front/src/partials/index.css b/app/internal/view/front/src/partials/index.css new file mode 100644 index 0000000..edbb98b --- /dev/null +++ b/app/internal/view/front/src/partials/index.css @@ -0,0 +1,2 @@ +@import './header/index.css'; +@import './footer/index.css'; diff --git a/app/internal/view/front/src/partials/index.ts b/app/internal/view/front/src/partials/index.ts new file mode 100644 index 0000000..64f5e4b --- /dev/null +++ b/app/internal/view/front/src/partials/index.ts @@ -0,0 +1 @@ +import './footer'; diff --git a/app/internal/view/front/src/share/index.css b/app/internal/view/front/src/share/index.css new file mode 100644 index 0000000..4b8a222 --- /dev/null +++ b/app/internal/view/front/src/share/index.css @@ -0,0 +1 @@ +@import './ui/index.css'; diff --git a/app/internal/view/front/src/share/type/entity.ts b/app/internal/view/front/src/share/type/entity.ts new file mode 100644 index 0000000..c23de36 --- /dev/null +++ b/app/internal/view/front/src/share/type/entity.ts @@ -0,0 +1,10 @@ +export interface Entity { + ID?: string; + CreatedAt?: Date; + UpdatedAt?: Date; +} + +export interface FileEntity extends Entity { + ALT?: string; + SRC?: string; +} diff --git a/app/internal/view/front/src/share/ui/btn/index.css b/app/internal/view/front/src/share/ui/btn/index.css new file mode 100644 index 0000000..09742e5 --- /dev/null +++ b/app/internal/view/front/src/share/ui/btn/index.css @@ -0,0 +1,46 @@ +@import './primary/index.css'; + +.btn { + padding: 10px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + text-align: center; + cursor: pointer; +} + +/* spinner styles */ +.btn.loading { + position: relative; + --spinner-size: 32px; +} + +.btn.loading::after { + content: ""; + width: var(--spinner-size); + height: var(--spinner-size); + border: 2px solid red; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Disable button styles */ +.btn.disabled { + opacity: 0.6; + pointer-events: none; +} + +@keyframes spin { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} diff --git a/app/internal/view/front/src/share/ui/btn/index.tmpl b/app/internal/view/front/src/share/ui/btn/index.tmpl new file mode 100644 index 0000000..cb0c64a --- /dev/null +++ b/app/internal/view/front/src/share/ui/btn/index.tmpl @@ -0,0 +1,4 @@ + diff --git a/app/internal/view/front/src/share/ui/btn/index.ts b/app/internal/view/front/src/share/ui/btn/index.ts new file mode 100644 index 0000000..6390c21 --- /dev/null +++ b/app/internal/view/front/src/share/ui/btn/index.ts @@ -0,0 +1,9 @@ +export function LoadingSpin(btn: Element, isLoading: boolean, size: string = "32px"): void { + if (!isLoading) { + btn.classList.remove("loading", "disabled"); + (btn as HTMLElement).style.removeProperty('--spinner-size'); + return; + } + btn.classList.add("loading", "disabled"); + (btn as HTMLElement).style.setProperty('--spinner-size', size); +} diff --git a/app/internal/view/front/src/share/ui/btn/primary/index.css b/app/internal/view/front/src/share/ui/btn/primary/index.css new file mode 100644 index 0000000..eed9356 --- /dev/null +++ b/app/internal/view/front/src/share/ui/btn/primary/index.css @@ -0,0 +1,5 @@ +.primary { + background: #0050ae; + color: #ffffff; + border: 1px solid #0474cd; +} diff --git a/app/internal/view/front/src/share/ui/btn/primary/index.tmpl b/app/internal/view/front/src/share/ui/btn/primary/index.tmpl new file mode 100644 index 0000000..3773f28 --- /dev/null +++ b/app/internal/view/front/src/share/ui/btn/primary/index.tmpl @@ -0,0 +1 @@ +{% include "share/ui/btn/index.tmpl" with class="primary " + class %} diff --git a/app/internal/view/front/src/share/ui/btn/secondary/index.tmpl b/app/internal/view/front/src/share/ui/btn/secondary/index.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/app/internal/view/front/src/share/ui/error/index.css b/app/internal/view/front/src/share/ui/error/index.css new file mode 100644 index 0000000..7d1e3d5 --- /dev/null +++ b/app/internal/view/front/src/share/ui/error/index.css @@ -0,0 +1,8 @@ +.su_error { + color: red; + margin: 0.2rem 0.16rem; +} + +.su_error_disable { + display: none; +} diff --git a/app/internal/view/front/src/share/ui/error/index.tmpl b/app/internal/view/front/src/share/ui/error/index.tmpl new file mode 100644 index 0000000..09c89ab --- /dev/null +++ b/app/internal/view/front/src/share/ui/error/index.tmpl @@ -0,0 +1,2 @@ +
{{ error }}
+
diff --git a/app/internal/view/front/src/share/ui/error/index.ts b/app/internal/view/front/src/share/ui/error/index.ts new file mode 100644 index 0000000..6478ec1 --- /dev/null +++ b/app/internal/view/front/src/share/ui/error/index.ts @@ -0,0 +1,37 @@ +let focus = false; +export function ErrorMessage(form: HTMLFormElement | null, name: string): void { + if (form === null) return; + + const element = document.querySelector( + "input[name=" + name + "], select[name=" + name + "], textarea[name=" + name + "]", + ); + if (element === null) return; + + const errorElement = document.getElementById("error_" + name); + if (errorElement === null) return; + + if (!focus) { + focus = true; + element.focus(); + // make focus false after 1 second + setTimeout(() => { + focus = false; + }, 1000); + } + + errorElement.classList.remove("su_error_disable"); + clearErrorMessage(element, errorElement); +} + +// Clear error on change input +function clearErrorMessage( + input: HTMLInputElement | HTMLSelectElement, + errorElement: Element | null, +): void { + if (errorElement === null) return; + input.addEventListener("input", () => { + errorElement.classList.add("su_error_disable"); + input.removeEventListener("input", () => { }); + focus = false; + }); +} diff --git a/app/internal/view/front/src/share/ui/index.css b/app/internal/view/front/src/share/ui/index.css new file mode 100644 index 0000000..926cfad --- /dev/null +++ b/app/internal/view/front/src/share/ui/index.css @@ -0,0 +1,2 @@ +@import './btn/index.css'; +@import './error/index.css'; diff --git a/app/internal/view/front/src/share/ui/input/index.css b/app/internal/view/front/src/share/ui/input/index.css new file mode 100644 index 0000000..7d91af8 --- /dev/null +++ b/app/internal/view/front/src/share/ui/input/index.css @@ -0,0 +1,27 @@ +.su_input { + width: 100%; + margin: 15px 0; +} + +.su_input label { + margin: 0 1px; +} + +.su_input input { + width: 100%; + font-size: 16px; + padding: 8px; + border: 1px solid gray; + border-radius: 5px; + margin: 4px 0; +} + +.su_input input.error { + border: 1px solid red; +} + +/* .error-message { */ +/* color: red; */ +/* font-size: 0.875em; */ +/* margin-top: 4px; */ +/* } */ diff --git a/app/internal/view/front/src/share/ui/input/index.tmpl b/app/internal/view/front/src/share/ui/input/index.tmpl new file mode 100644 index 0000000..46491cd --- /dev/null +++ b/app/internal/view/front/src/share/ui/input/index.tmpl @@ -0,0 +1,23 @@ +
+ + + {% include "share/ui/error/index.tmpl" with name=name error=error %} +
diff --git a/app/internal/view/front/src/share/ui/input/password/index.tmpl b/app/internal/view/front/src/share/ui/input/password/index.tmpl new file mode 100644 index 0000000..d2303ea --- /dev/null +++ b/app/internal/view/front/src/share/ui/input/password/index.tmpl @@ -0,0 +1 @@ +{% include "share/ui/input/index.tmpl" with type="password" %} diff --git a/app/internal/view/front/src/share/ui/input/text/index.tmpl b/app/internal/view/front/src/share/ui/input/text/index.tmpl new file mode 100644 index 0000000..8ca56d8 --- /dev/null +++ b/app/internal/view/front/src/share/ui/input/text/index.tmpl @@ -0,0 +1 @@ +{% include "share/ui/input/index.tmpl" with type="text" %} diff --git a/app/internal/view/front/src/share/utils/client/index.ts b/app/internal/view/front/src/share/utils/client/index.ts new file mode 100644 index 0000000..b75132c --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/index.ts @@ -0,0 +1,83 @@ +import { sendRequest } from "./request"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export type RequestOptions = { + method?: HttpMethod; + body?: unknown; + headers?: Record; + auth?: boolean; + credentials?: RequestCredentials; +}; + +export type Meta = { + Total: number; + Limit: number; + Page: number; + Count: number; +}; + +export interface APIResponse { + data?: any | null; + meta?: Meta | null; + message?: string; + status?: number; + statusText?: string; + error?: string; + url?: string; +} + +export interface RequestError { + message: string; + status: number; + statusText: string; + body?: unknown; +} + +export const Get = async (endpoint: string): Promise => { + const options: RequestOptions = { + method: "GET", + }; + return await sendRequest(endpoint, options); +} + +export const Post = async (url: string, body: any): Promise => { + const options: RequestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(body), + }; + + return await sendRequest(url, options); +}; + +export const Patch = async (url: string, body: any): Promise => { + const options: RequestOptions = { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }; + + return await sendRequest(url, options); +}; + +export const Delete = async (url: string): Promise => { + const options: RequestOptions = { + method: "DELETE", + }; + + return await sendRequest(url, options); +}; + + +export const Client = { + Get: Get, + Post: Post, + Patch: Patch, + Delete: Delete, +} diff --git a/app/internal/view/front/src/share/utils/client/request.ts b/app/internal/view/front/src/share/utils/client/request.ts new file mode 100644 index 0000000..e61cea1 --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/request.ts @@ -0,0 +1,85 @@ +import type { RequestOptions } from "."; +import { getAPIResponse } from "./response"; + +const API_URL: string = (() => { + const url = import.meta.env.VITE_API_URL; + + if (!url) { + throw new Error("VITE_API_URL is not defined"); + } + + return `${url.replace(/\/$/, "")}/api/`; +})(); + +function getJwt(): string | null { + try { + return localStorage.getItem("jwt"); + } catch { + return null; + } +} + +function buildBody(body: unknown): BodyInit | undefined { + if (body == null) { + return undefined; + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + return body; + } + + if (typeof body === "string") { + return body; + } + + return JSON.stringify(body); +} + +function buildHeaders( + extraHeaders: Record = {}, + auth = true, +): Headers { + const headers = new Headers(extraHeaders); + + if (auth) { + const token = getJwt(); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } + + return headers; +} + +export async function sendRequest( + url: string, + options: RequestOptions = {}, +) { + const { + method = "GET", + body, + headers = {}, + auth = true, + credentials = "include", + } = options; + + try { + const res = await fetch(API_URL + url, { + method, + headers: buildHeaders(headers, auth), + credentials, + body: buildBody(body), + }); + + return await getAPIResponse(res) + + } catch (error) { + return { + message: error instanceof Error ? error.message : "Unknown error", + status: 0, + statusText: "NETWORK_ERROR", + error: error instanceof Error ? error.stack : String(error), + }; + } +} diff --git a/app/internal/view/front/src/share/utils/client/response/file.ts b/app/internal/view/front/src/share/utils/client/response/file.ts new file mode 100644 index 0000000..1ac30b6 --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/response/file.ts @@ -0,0 +1,26 @@ +import type { APIResponse } from ".."; + +export async function respondFile(res: Response): Promise { + const blob = await res.blob(); + return { + data: blob, + message: "", + status: res.status, + statusText: res.statusText, + url: res.url, + }; +} + +export function downloadFile(fileName: string, blob: Blob) { + const a = window.document.createElement("a"); + const objectUrl = window.URL.createObjectURL(blob); + + a.href = objectUrl; + a.download = fileName; + a.click(); + a.remove(); + + setTimeout(() => { + window.URL.revokeObjectURL(objectUrl); + }, 1000); +} diff --git a/app/internal/view/front/src/share/utils/client/response/html.ts b/app/internal/view/front/src/share/utils/client/response/html.ts new file mode 100644 index 0000000..84f48b7 --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/response/html.ts @@ -0,0 +1,23 @@ +import type { APIResponse } from ".."; + +export async function respondHTML(res: Response): Promise { + + if (!res.ok) { + return { + message: "Server error", + status: res.status, + statusText: res.statusText, + url: res.url, + }; + } + + const text = await res.text(); + const respAPI: APIResponse = { + data: text, + message: "", + status: res.status, + statusText: res.statusText, + url: res.url, + }; + return respAPI; +} diff --git a/app/internal/view/front/src/share/utils/client/response/index.ts b/app/internal/view/front/src/share/utils/client/response/index.ts new file mode 100644 index 0000000..4804971 --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/response/index.ts @@ -0,0 +1,27 @@ +import type { APIResponse } from ".."; +import { respondFile } from "./file"; +import { respondHTML } from "./html"; +import { respondJSON } from "./json"; + +export async function getAPIResponse(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + + // return json + if (contentType.includes("application/json")) { + return await respondJSON(res); + } + + // return file + if ( + contentType.includes("application/octet-stream") || + contentType.includes("image/") || + contentType.includes("application/pdf") || + contentType.includes("audio/") || + contentType.includes("video/") + ) { + return await respondFile(res); + } + + // return html or text + return await respondHTML(res); +} diff --git a/app/internal/view/front/src/share/utils/client/response/json.ts b/app/internal/view/front/src/share/utils/client/response/json.ts new file mode 100644 index 0000000..678149a --- /dev/null +++ b/app/internal/view/front/src/share/utils/client/response/json.ts @@ -0,0 +1,44 @@ +import type { APIResponse } from "../index"; + +export async function respondJSON(res: Response): Promise { + + if (!res.ok) { + let resAPI: APIResponse = {} + // App error + const data = await res.json(); + if (!data) { + resAPI = { + message: "Empty response body", + status: res.status, + statusText: res.statusText, + url: res.url, + }; + return resAPI; + } + + if (res.status === 401 && !res.url.includes("/auth/login")) { + window.location.href = "/auth/login"; + } + + resAPI = { + message: data.message || res.statusText, + status: res.status, + statusText: res.statusText, + data: data.data || null, + url: res.url, + }; + return resAPI; + } + + + const data = await res.json(); + const respAPI: APIResponse = { + data: data.data, + meta: data.meta, + message: data.message || "", + status: res.status, + statusText: res.statusText, + url: res.url, + }; + return respAPI; +} diff --git a/app/internal/view/front/src/share/utils/navigate.ts b/app/internal/view/front/src/share/utils/navigate.ts new file mode 100644 index 0000000..fda63cd --- /dev/null +++ b/app/internal/view/front/src/share/utils/navigate.ts @@ -0,0 +1,25 @@ +export function NavigateBack() { + const currentHost = window.location.hostname; + + const referrer = document.referrer; // Previous page URL + let referrerHost = ""; + + if (referrer) { + const referrerURL = new URL(referrer); + referrerHost = referrerURL.hostname; + } + + if (currentHost === referrerHost) { + if (referrer.startsWith('/auth/')) { + window.location.href = "/"; + return; + } + window.location.href = referrer; // Redirect to the referrer page + } else { + window.location.href = "/"; + } +} + +export function NavigateAfterLogin() { + window.location.href = "/qr"; +} diff --git a/app/internal/view/front/src/styles/globals.css b/app/internal/view/front/src/styles/globals.css new file mode 100644 index 0000000..0c973ce --- /dev/null +++ b/app/internal/view/front/src/styles/globals.css @@ -0,0 +1,97 @@ +:root { + --bg-color: #fbfaf5; + --font-color: #2e3237; + --border-color: #e1e1e1; + --input-bg-color: #ffffff; + --error-color: #ff0000; +} + +body { + background-color: var(--bg-color); + color: var(--font-color); + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + font-weight: 300; + letter-spacing: 0.04em; + min-width: 320px; +} + +a { + color: var(--font-color); + text-decoration: none; +} + +input { + background-color: var(--input-bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--font-color); + padding: 8px; + width: 100%; +} + + + +select { + background-color: var(--input-bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--font-color); + padding: 8px; + margin: 0; + box-shadow: none; + line-height: 1.2; + /* disable Firefox default arrow */ + -moz-appearance: none; + /* disable modern browsers’ generic styling */ + appearance: none; +} + +/* 2) Custom arrow via SVG data-URI */ +select { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0l5 6 5-6' fill='%23666'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75em center; + background-size: 0.65em auto; +} + +/* 3) Optional: focus outline */ +select:focus { + outline: none; + border-color: #66afe9; + box-shadow: 0 0 0 3px rgba(102, 175, 233, 0.3); +} + + +.hidden { + display: none !important; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 600; + margin: 0; +} + +h1 { + font-size: 2.5rem; + margin: 2rem 0; +} + +h2 { + font-size: 2rem; +} + +h3 { + font-size: 1.8rem; +} + +main { + min-height: 96vh; +} diff --git a/app/internal/view/front/src/styles/reset.css b/app/internal/view/front/src/styles/reset.css new file mode 100644 index 0000000..6c9006a --- /dev/null +++ b/app/internal/view/front/src/styles/reset.css @@ -0,0 +1,79 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html { + touch-action: manipulation; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + touch-action: manipulation; + -webkit-text-size-adjust: none; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; + text-align: center; +} + +#root, +#__next { + isolation: isolate; +} + +ul, +li { + list-style: none; + margin: 0; + padding: 0; +} + +button { + cursor: pointer; + background: transparent; + border: none; + padding: 0; +} diff --git a/app/internal/view/front/src/styles/typography.css b/app/internal/view/front/src/styles/typography.css new file mode 100644 index 0000000..e69de29 diff --git a/app/internal/view/front/tsconfig.json b/app/internal/view/front/tsconfig.json new file mode 100644 index 0000000..87cdec0 --- /dev/null +++ b/app/internal/view/front/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/app/internal/view/front/vite.config.ts b/app/internal/view/front/vite.config.ts new file mode 100644 index 0000000..0f68139 --- /dev/null +++ b/app/internal/view/front/vite.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from 'vite' +import path from 'node:path' + +export default defineConfig({ + base: '/public/', + publicDir: false, + + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + + build: { + outDir: 'public', + emptyOutDir: false, + cssCodeSplit: false, + + rollupOptions: { + input: { + app: path.resolve(__dirname, 'src/index.ts'), + style: path.resolve(__dirname, 'src/index.css'), + }, + output: { + entryFileNames: 'index.js', + assetFileNames: (assetInfo) => { + const name = assetInfo.name ?? '' + + if (name.endsWith('.css')) { + return 'index.css' + } + + if (/\.(woff|woff2|eot|ttf|otf)$/i.test(name)) { + return 'fonts/[name][extname]' + } + + return '[name][extname]' + }, + }, + }, + }, + + server: { + host: '0.0.0.0', + port: 3000, + allowedHosts: true, + }, +}) diff --git a/app/migrations/20260401202343_c_schema.sql b/app/migrations/20260401202343_c_schema.sql new file mode 100644 index 0000000..f3617a6 --- /dev/null +++ b/app/migrations/20260401202343_c_schema.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +CREATE SCHEMA IF NOT EXISTS qrcode; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP SCHEMA IF EXISTS qrcode; +-- +goose StatementEnd diff --git a/app/migrations/20260402070545_c_user.sql b/app/migrations/20260402070545_c_user.sql new file mode 100644 index 0000000..e4c8357 --- /dev/null +++ b/app/migrations/20260402070545_c_user.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE qrcode.users ( + id uuid PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS qrcode.users; +-- +goose StatementEnd diff --git a/app/migrations/20260406153310_c_qr.sql b/app/migrations/20260406153310_c_qr.sql new file mode 100644 index 0000000..876d14d --- /dev/null +++ b/app/migrations/20260406153310_c_qr.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE qrcode.qrs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + slug TEXT NOT NULL, + url TEXT NOT NULL, + visits INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP NULL, + + CONSTRAINT fk_qrs_user + FOREIGN KEY (user_id) + REFERENCES qrcode.users(id) + ON DELETE CASCADE +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS qrcode.qrs; +-- +goose StatementEnd diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..34f3e49 --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,67 @@ +--- +networks: + qrcode: + name: qrcode + driver: bridge + ipam: + driver: default + config: + - subnet: "10.0.3.0/28" +services: + qrcode-web: + ports: [80:80] + networks: + qrcode: + ipv4_address: 10.0.3.2 + qrcode-app: + ports: + - 3000:3000 + networks: + qrcode: + ipv4_address: 10.0.3.3 + qrcode-postgres: + env_file: ./app/.env + networks: + qrcode: + ipv4_address: 10.0.3.4 + qrcode-adminer: + networks: + qrcode: + ipv4_address: 10.0.3.5 + ports: + - 8080:8080 + qrcode-pgadmin: + networks: + qrcode: + ipv4_address: 10.0.3.6 + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - 8081:80 + qrcode-front: + networks: + qrcode: + ipv4_address: 10.0.3.7 + ports: + - 3003:3000 + - 3004:5173 + qrcode-minio: + command: server --console-address ":9090" /data + environment: + MINIO_ROOT_USER: adminminio + MINIO_ROOT_PASSWORD: adminminio + MINIO_SERVER_URL: http://localhost:9000 + MINIO_REGION: eu-central-1 + networks: + qrcode: + ipv4_address: 10.0.3.8 + ports: + - 9000:9000 + - 9091:9090 + volumes: + - ./tmp/minio:/data + - "/etc/timezone:/etc/timezone:ro" + - "/etc/localtime:/etc/localtime:ro" + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7fa7b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +--- +services: + qrcode-web: + build: + context: .dev/nginx/. + dockerfile: Dockerfile + volumes: + - ./.dev/nginx/templates:/etc/nginx/templates + - ./app/tmp:/app/tmp + depends_on: [qrcode-app, qrcode-front] + qrcode-app: + build: + context: .dev/app/. + dockerfile: Dockerfile + working_dir: /app + volumes: + - ./app:/app + depends_on: [qrcode-postgres] + qrcode-postgres: + image: postgres:14.2-bullseye + environment: + POSTGRES_DB: qrcode + command: + - "postgres" + - "-c" + - "log_statement=all" + qrcode-adminer: + image: adminer + qrcode-pgadmin: + image: dpage/pgadmin4 + qrcode-front: + build: + context: .dev/front/. + dockerfile: Dockerfile + volumes: + - ./app/internal/view/front:/app + working_dir: /app + user: node + qrcode-minio: + image: minio/minio:RELEASE.2023-09-30T07-02-29Z + + +