init
This commit is contained in:
37
.dev/.mk/develop.mk
Normal file
37
.dev/.mk/develop.mk
Normal file
@ -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
|
||||||
26
.dev/.mk/help/mark.mk
Normal file
26
.dev/.mk/help/mark.mk
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Color font
|
||||||
|
<cWhite> = \033[30m
|
||||||
|
<cRed> = \033[31m
|
||||||
|
<cGreen> = \033[32m
|
||||||
|
<cYellow> = \033[33m
|
||||||
|
<cBlue> = \033[34m
|
||||||
|
<cPurple> = \033[35m
|
||||||
|
<cTurquoise> = \033[36m
|
||||||
|
<cGray> = \033[37m
|
||||||
|
|
||||||
|
# Select
|
||||||
|
<sWhite> = \033[40m
|
||||||
|
<sRed> = \033[41m
|
||||||
|
<sGreen> = \033[42m
|
||||||
|
<sYellow> = \033[43m
|
||||||
|
<sBlue> = \033[44m
|
||||||
|
<sPurple> = \033[45m
|
||||||
|
<sTurquoise> = \033[46m
|
||||||
|
<sGray> = \033[47m
|
||||||
|
|
||||||
|
# Emphasis (typography)
|
||||||
|
<b> = \033[1m
|
||||||
|
<u> = \033[4m
|
||||||
|
</> = \033[0m
|
||||||
|
<br> = \n
|
||||||
|
|
||||||
11
.dev/.mk/help/msg.mk
Normal file
11
.dev/.mk/help/msg.mk
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.PHONY: help
|
||||||
|
help: help_message
|
||||||
|
|
||||||
|
help_message:
|
||||||
|
@awk 'BEGIN {FS = ":.*##"; \
|
||||||
|
printf "$(<b>)$(<cYellow>)Usage:$(</>)$(</>) \
|
||||||
|
$(<br>)make $(<cGreen>)<target>$(</>) $(<cBlue>)\"<arguments>\"$(</>)️\
|
||||||
|
$(<br>)$(<br>)$(<b>)$(<cYellow>)Available commands:$(</>)$(</>) \
|
||||||
|
$(<br>)"}/^[a-zA-Z_-]+:.*?##/{ printf " $(<cGreen>)%-10s$(</>) %s$(<br>)", $$1, $$2 } /^##@/{ printf "\
|
||||||
|
$(<br>)$(<b>)%s$(</>)$(<br>)", substr($$0, 5) }'$(MAKEFILE_LIST)
|
||||||
|
|
||||||
14
.dev/.mk/migratioin.mk
Normal file
14
.dev/.mk/migratioin.mk
Normal file
@ -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'
|
||||||
40
.dev/.mk/system.mk
Normal file
40
.dev/.mk/system.mk
Normal file
@ -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)
|
||||||
22
.dev/.mk/test.mk
Normal file
22
.dev/.mk/test.mk
Normal file
@ -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)'
|
||||||
|
|
||||||
39
.dev/app/Dockerfile
Normal file
39
.dev/app/Dockerfile
Normal file
@ -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
|
||||||
17
.dev/front/Dockerfile
Normal file
17
.dev/front/Dockerfile
Normal file
@ -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" ]
|
||||||
5
.dev/nginx/Dockerfile
Normal file
5
.dev/nginx/Dockerfile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
FROM nginx:1.23-alpine
|
||||||
|
|
||||||
|
RUN apk update && apk upgrade && apk add --no-cache openssl
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
43
.dev/nginx/templates/default.conf.template
Normal file
43
.dev/nginx/templates/default.conf.template
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# app
|
||||||
|
app/.env
|
||||||
|
app/build
|
||||||
|
app/tmp
|
||||||
|
app/vendor
|
||||||
|
|
||||||
|
# env
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
#other
|
||||||
|
tmp
|
||||||
|
!trash
|
||||||
13
Makefile
Normal file
13
Makefile
Normal file
@ -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
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@ -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
|
||||||
37
app/.air.toml
Normal file
37
app/.air.toml
Normal file
@ -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
|
||||||
32
app/.env.example
Normal file
32
app/.env.example
Normal file
@ -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
|
||||||
27
app/cmd/app/main.go
Normal file
27
app/cmd/app/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
6
app/embed.go
Normal file
6
app/embed.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed internal/view/front/src/* internal/view/front/public
|
||||||
|
var TemplatesFS embed.FS
|
||||||
62
app/go.mod
Normal file
62
app/go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
145
app/go.sum
Normal file
145
app/go.sum
Normal file
@ -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=
|
||||||
43
app/internal/app/bootstrap/dev.go
Normal file
43
app/internal/app/bootstrap/dev.go
Normal file
@ -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
|
||||||
|
}
|
||||||
47
app/internal/app/bootstrap/prod.go
Normal file
47
app/internal/app/bootstrap/prod.go
Normal file
@ -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
|
||||||
|
}
|
||||||
11
app/internal/app/dto/qr.go
Normal file
11
app/internal/app/dto/qr.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
12
app/internal/app/dto/user.go
Normal file
12
app/internal/app/dto/user.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
185
app/internal/app/handler/api/qr.go
Normal file
185
app/internal/app/handler/api/qr.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
122
app/internal/app/handler/api/user.go
Normal file
122
app/internal/app/handler/api/user.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
7
app/internal/app/handler/error.go
Normal file
7
app/internal/app/handler/error.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = "invalid request"
|
||||||
|
ErrUnauthorized = "unauthorized"
|
||||||
|
ErrInternalError = "internal error"
|
||||||
|
)
|
||||||
30
app/internal/app/handler/web/auth.go
Normal file
30
app/internal/app/handler/web/auth.go
Normal file
@ -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, "/")
|
||||||
|
}
|
||||||
14
app/internal/app/handler/web/error.go
Normal file
14
app/internal/app/handler/web/error.go
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
27
app/internal/app/handler/web/home.go
Normal file
27
app/internal/app/handler/web/home.go
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
16
app/internal/app/handler/web/impressum.go
Normal file
16
app/internal/app/handler/web/impressum.go
Normal file
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
61
app/internal/app/handler/web/qr.go
Normal file
61
app/internal/app/handler/web/qr.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
15
app/internal/app/handler/web/seo.go
Normal file
15
app/internal/app/handler/web/seo.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
23
app/internal/app/handler/web/static.go
Normal file
23
app/internal/app/handler/web/static.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
12
app/internal/app/repository/err.go
Normal file
12
app/internal/app/repository/err.go
Normal file
@ -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"
|
||||||
|
)
|
||||||
183
app/internal/app/repository/qr.go
Normal file
183
app/internal/app/repository/qr.go
Normal file
@ -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
|
||||||
|
}
|
||||||
134
app/internal/app/repository/user.go
Normal file
134
app/internal/app/repository/user.go
Normal file
@ -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
|
||||||
|
}
|
||||||
24
app/internal/app/router/api.go
Normal file
24
app/internal/app/router/api.go
Normal file
@ -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"))
|
||||||
|
}
|
||||||
34
app/internal/app/router/init.go
Normal file
34
app/internal/app/router/init.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/internal/app/router/web.go
Normal file
51
app/internal/app/router/web.go
Normal file
@ -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{}
|
||||||
|
}
|
||||||
50
app/internal/app/service/cache/load.go
vendored
Normal file
50
app/internal/app/service/cache/load.go
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
45
app/internal/app/service/response/response.go
Normal file
45
app/internal/app/service/response/response.go
Normal file
@ -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
|
||||||
|
}
|
||||||
37
app/internal/app/service/shorturl/short.go
Normal file
37
app/internal/app/service/shorturl/short.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
189
app/internal/app/service/view/template.go
Normal file
189
app/internal/app/service/view/template.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/internal/app/service/view/version.go
Normal file
8
app/internal/app/service/view/version.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// GetAppVersion get version frpm env
|
||||||
|
func GetAppVersion() string {
|
||||||
|
return os.Getenv("APP_VERSION")
|
||||||
|
}
|
||||||
47
app/internal/app/usecase/auth/cookie.go
Normal file
47
app/internal/app/usecase/auth/cookie.go
Normal file
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/internal/app/usecase/auth/err.go
Normal file
16
app/internal/app/usecase/auth/err.go
Normal file
@ -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")
|
||||||
|
)
|
||||||
36
app/internal/app/usecase/auth/signin.go
Normal file
36
app/internal/app/usecase/auth/signin.go
Normal file
@ -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
|
||||||
|
}
|
||||||
13
app/internal/app/usecase/auth/signup.go
Normal file
13
app/internal/app/usecase/auth/signup.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
130
app/internal/app/usecase/auth/token.go
Normal file
130
app/internal/app/usecase/auth/token.go
Normal file
@ -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
|
||||||
|
}
|
||||||
33
app/internal/app/usecase/qr.go
Normal file
33
app/internal/app/usecase/qr.go
Normal file
@ -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
|
||||||
|
}
|
||||||
18
app/internal/app/usecase/qr_generate.go
Normal file
18
app/internal/app/usecase/qr_generate.go
Normal file
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
27
app/internal/app/usecase/redirect.go
Normal file
27
app/internal/app/usecase/redirect.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
39
app/internal/infr/db/pgx.go
Normal file
39
app/internal/infr/db/pgx.go
Normal file
@ -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
|
||||||
|
}
|
||||||
18
app/internal/infr/env/init.go
vendored
Normal file
18
app/internal/infr/env/init.go
vendored
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
app/internal/infr/qrgenerator/init.go
Normal file
11
app/internal/infr/qrgenerator/init.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
15
app/internal/infr/qrgenerator/png.go
Normal file
15
app/internal/infr/qrgenerator/png.go
Normal file
@ -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
|
||||||
|
}
|
||||||
14
app/internal/infr/qrgenerator/svg.go
Normal file
14
app/internal/infr/qrgenerator/svg.go
Normal file
@ -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
|
||||||
|
}
|
||||||
168
app/internal/infr/s3/s3.go
Normal file
168
app/internal/infr/s3/s3.go
Normal file
@ -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{})
|
||||||
|
}
|
||||||
77
app/internal/middleware/auth.go
Normal file
77
app/internal/middleware/auth.go
Normal file
@ -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
|
||||||
|
}
|
||||||
101
app/internal/qrcode/entity/qr.go
Normal file
101
app/internal/qrcode/entity/qr.go
Normal file
@ -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
|
||||||
|
}
|
||||||
187
app/internal/qrcode/entity/qr_test.go
Normal file
187
app/internal/qrcode/entity/qr_test.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/internal/qrcode/entity/user.go
Normal file
124
app/internal/qrcode/entity/user.go
Normal file
@ -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
|
||||||
|
}
|
||||||
159
app/internal/qrcode/entity/user_test.go
Normal file
159
app/internal/qrcode/entity/user_test.go
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/internal/qrcode/error.go
Normal file
11
app/internal/qrcode/error.go
Normal file
@ -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: ")
|
||||||
|
)
|
||||||
16
app/internal/qrcode/service/auth/email.go
Normal file
16
app/internal/qrcode/service/auth/email.go
Normal file
@ -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
|
||||||
|
}
|
||||||
20
app/internal/qrcode/service/auth/pass.go
Normal file
20
app/internal/qrcode/service/auth/pass.go
Normal file
@ -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
|
||||||
|
}
|
||||||
8
app/internal/qrcode/service/entity/entity.go
Normal file
8
app/internal/qrcode/service/entity/entity.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
// IEntity is a default entity interface
|
||||||
|
type IEntity interface {
|
||||||
|
GetID() uuid.UUID
|
||||||
|
}
|
||||||
25
app/internal/qrcode/service/url/validation.go
Normal file
25
app/internal/qrcode/service/url/validation.go
Normal file
@ -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
|
||||||
|
}
|
||||||
66
app/internal/qrcode/vo/qrimg.go
Normal file
66
app/internal/qrcode/vo/qrimg.go
Normal file
@ -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
|
||||||
|
}
|
||||||
1
app/internal/view/front/.env.example
Normal file
1
app/internal/view/front/.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="http://127.0.0.1/"
|
||||||
26
app/internal/view/front/.gitignore
vendored
Normal file
26
app/internal/view/front/.gitignore
vendored
Normal file
@ -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?
|
||||||
|
|
||||||
8
app/internal/view/front/.vite/deps/_metadata.json
Normal file
8
app/internal/view/front/.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "3fd37e12",
|
||||||
|
"configHash": "5d677169",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "ba1e7c4a",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
app/internal/view/front/.vite/deps/package.json
Normal file
3
app/internal/view/front/.vite/deps/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
7
app/internal/view/front/index.css
Normal file
7
app/internal/view/front/index.css
Normal file
@ -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';
|
||||||
2
app/internal/view/front/index.ts
Normal file
2
app/internal/view/front/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import './src/partials';
|
||||||
|
import './src/components';
|
||||||
1905
app/internal/view/front/package-lock.json
generated
Normal file
1905
app/internal/view/front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
app/internal/view/front/package.json
Normal file
17
app/internal/view/front/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
app/internal/view/front/public/images/icons/qr.svg
Normal file
1
app/internal/view/front/public/images/icons/qr.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 55 KiB |
4
app/internal/view/front/public/robots.txt
Normal file
4
app/internal/view/front/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /impressum
|
||||||
|
Disallow: /qr
|
||||||
|
Disallow: /auth/recover
|
||||||
10
app/internal/view/front/src/components/auth/api.ts
Normal file
10
app/internal/view/front/src/components/auth/api.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Client, type APIResponse } from "../../share/utils/client";
|
||||||
|
import type { User } from "./entity";
|
||||||
|
|
||||||
|
export function SignUp(user: User): Promise<APIResponse> {
|
||||||
|
return Client.Post("auth/signup", user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Login(email: string, password: string): Promise<APIResponse> {
|
||||||
|
return Client.Post("auth/login", { email, password });
|
||||||
|
}
|
||||||
21
app/internal/view/front/src/components/auth/entity.ts
Normal file
21
app/internal/view/front/src/components/auth/entity.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
2
app/internal/view/front/src/components/auth/index.css
Normal file
2
app/internal/view/front/src/components/auth/index.css
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import './signup/index.css';
|
||||||
|
@import './login/index.css';
|
||||||
2
app/internal/view/front/src/components/auth/index.ts
Normal file
2
app/internal/view/front/src/components/auth/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import './signup'
|
||||||
|
import './login'
|
||||||
28
app/internal/view/front/src/components/auth/login/index.css
Normal file
28
app/internal/view/front/src/components/auth/login/index.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
11
app/internal/view/front/src/components/auth/login/index.tmpl
Normal file
11
app/internal/view/front/src/components/auth/login/index.tmpl
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<div id="c_auth_login">
|
||||||
|
<form id="c_auth_login__form">
|
||||||
|
{% 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" %}
|
||||||
|
</form>
|
||||||
|
<div id="c_auth_login__error"></div>
|
||||||
|
<div class="c_auth_login__links">
|
||||||
|
<a href="/auth/signup">Noch keinen Account? Jetzt registrieren</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
58
app/internal/view/front/src/components/auth/login/index.ts
Normal file
58
app/internal/view/front/src/components/auth/login/index.ts
Normal file
@ -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();
|
||||||
21
app/internal/view/front/src/components/auth/signup/index.css
Normal file
21
app/internal/view/front/src/components/auth/signup/index.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
<div id="c_auth_signup">
|
||||||
|
<form id="c_auth_signup__form">
|
||||||
|
{% 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" %}
|
||||||
|
</form>
|
||||||
|
<div id="c_auth_signup__error"></div>
|
||||||
|
</div>
|
||||||
75
app/internal/view/front/src/components/auth/signup/index.ts
Normal file
75
app/internal/view/front/src/components/auth/signup/index.ts
Normal file
@ -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();
|
||||||
2
app/internal/view/front/src/components/index.css
Normal file
2
app/internal/view/front/src/components/index.css
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import './qr/index.css';
|
||||||
|
@import './auth/index.css';
|
||||||
3
app/internal/view/front/src/components/index.ts
Normal file
3
app/internal/view/front/src/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import "./qr";
|
||||||
|
import "./auth";
|
||||||
|
|
||||||
14
app/internal/view/front/src/components/qr/api.ts
Normal file
14
app/internal/view/front/src/components/qr/api.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Client, type APIResponse } from "../../share/utils/client";
|
||||||
|
import type { QR, QRGenerate } from "./entity";
|
||||||
|
|
||||||
|
export function Generate(qr: QRGenerate): Promise<APIResponse> {
|
||||||
|
return Client.Post("qr/generate", qr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Create(qr: QR): Promise<APIResponse> {
|
||||||
|
return Client.Post("qr", qr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Deelete(id: string): Promise<APIResponse> {
|
||||||
|
return Client.Delete("qr/" + id);
|
||||||
|
}
|
||||||
14
app/internal/view/front/src/components/qr/entity.ts
Normal file
14
app/internal/view/front/src/components/qr/entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<div id="c_qr_e_add">
|
||||||
|
<form id="c_qr_e_add__form">
|
||||||
|
{% 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" %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
25
app/internal/view/front/src/components/qr/event/add/index.ts
Normal file
25
app/internal/view/front/src/components/qr/event/add/index.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<form class="c_qr_e_generate {{ class }}">
|
||||||
|
{% if qr %}
|
||||||
|
<input type="hidden" name="url" value="{{ HOST }}/redirect/{{ qr.Slug() }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="field">
|
||||||
|
<label for="url">Enter URL:</label>
|
||||||
|
<input type="text" name="url" placeholder="Enter URL" value="https://">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="field">
|
||||||
|
<label for"format">Format:</label>
|
||||||
|
<select name="format">
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="svg">SVG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="size">Size (px):</label>
|
||||||
|
<input type="number" name="size" placeholder="Size (e.g., 250)" value="250">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Generate & Download</button>
|
||||||
|
</form>
|
||||||
@ -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<HTMLFormElement>;
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
@import './generate/index.css';
|
||||||
|
@import './add/index.css';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user