There will be a preview of the image here
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8245610 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: QR-gen tests + +on: + push: + branches: + - add* + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Extract branch name + run: echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Check out code + uses: actions/checkout@v4 + + - name: Linters + uses: golangci/golangci-lint-action@v6 + with: + version: v1.58 + + tests: + runs-on: ubuntu-latest + steps: + - name: Extract branch name + run: echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ^1.21.5 + + - name: Check out code + uses: actions/checkout@v3 + + - name: Unit tests + run: go test -v -count=1 -race -timeout=1m ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a8799c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +output/ +tests/output/ +tests/fonts/ +tests/archive.zip +temp/ +site/preview.jpg +*.exe diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..211e7cb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,87 @@ +run: + tests: true + +linters-settings: + funlen: + lines: 150 + statements: 80 + depguard: + rules: + main: + files: + - $all + - "!$test" + allow: + - $gostd + - github.com/skip2 + - github.com/fogleman + - github.com/go-chi + - github.com/TOsmanov + - github.com/ilyakaznacheev + - github.com/go-playground + - github.com/google/uuid + test: + files: + - "$test" + allow: + - $gostd + - github.com/stretchr + - github.com/TOsmanov + +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + # - deadcode + - depguard + - dogsled + - dupl + - durationcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - gci + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - gofmt + - gofumpt + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - gosimple + - govet + # - ifshort + - importas + - ineffassign + - lll + - makezero + - misspell + - nestif + - nilerr + - noctx + - nolintlint + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + # - structcheck + - stylecheck + - tagliatelle + - thelper + - tparallel + - typecheck + - unconvert + - unparam + - unused + # - varcheck + - wastedassign + - whitespace \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51745c3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM mirror.gcr.io/golang:1.21 AS builder +COPY ./ /usr/local/go/src/qr_gen/ +WORKDIR /usr/local/go/src/qr_gen +RUN go clean --modcache && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ +-mod=readonly -o qrgen /usr/local/go/src/qr_gen/server/main.go +FROM scratch +WORKDIR /app +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/local/go/src/qr_gen/qrgen /app/ +CMD ["/app/qrgen"] \ No newline at end of file diff --git a/README.md b/README.md index 74431fb..333e94a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,46 @@ -Batch QR Code Generator +# Batch QR Code Generator -## Quick start +[](https://github.com/TOsmanov/qr-gen/actions/workflows/tests.yml) -## QR +This application places a QR code on the background image, the data for QR codes is taken from a text file. The output will be a folder with the finished images in jpg format. +Each image will contain a qr code with an encrypted string from the data file. -## Text +## Quick start -To insert text, you need a font file in the `ttf` format. +### CLI -## Build +#### Build Build for Windows: ```bash -env GOOS=windows GOARCH=386 go build -o qr-gen.exe -``` \ No newline at end of file +env GOOS=windows GOARCH=386 go build -o qr-gen.exe cli/main.go +``` + +Build for Linux: +```bash +env GOOS=linux GOARCH=386 go build -o qrgen_cli cli/main.go +``` + +#### Using + +For generate images with QR-code, run: + +```bash +go run ./cmd/ -file ./tests/data.txt -background ./tests/background.jpg -size 120 -h-align 50 -v-align 75 -output output +``` + +##### Arguments + +- `-background` – The path to the background image (default "background.jpg"). +- `-file` – The path to the data file for QR codes (default "data.txt"). +- `-h-align` – Horizontal alignment as a percentage (default 50). +- `-output` – Folder with images (default "output"). +- `-qr` – Insert qr or text (default true). +- `-size` – Size of the upper image (default 200). +- `-v-align` – Vertical alignment as a percentage (default 50). + +**Text** + +To insert text, you need a font file in the `ttf` format. + +- `-font` – Font for text (default "./fonts/DroidSansMono.ttf"). diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..f214e30 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "log" + + qrgen "github.com/TOsmanov/qr-gen/qr-gen" +) + +var ( + params qrgen.Params + data string + backgroundImg string +) + +func init() { + if flag.Lookup("file") == nil { + flag.StringVar(&data, "file", "data.txt", "The path to the data file for QR codes") + } + if flag.Lookup("background") == nil { + flag.StringVar(&backgroundImg, "background", "background.jpg", "The path to the background image") + } + if flag.Lookup("size") == nil { + flag.IntVar(¶ms.Size, "size", 200, "Size of the upper image") + } + if flag.Lookup("horizontalAlign") == nil { + flag.IntVar(¶ms.HorizontalAlign, "h-align", 50, "Horizontal alignment as a percentage") + } + if flag.Lookup("verticalAlign") == nil { + flag.IntVar(¶ms.VerticalAlign, "v-align", 50, "Vertical alignment as a percentage") + } + if flag.Lookup("output") == nil { + flag.StringVar(¶ms.Output, "output", "output", "Folder with images") + } + if flag.Lookup("qr") == nil { + flag.BoolVar(¶ms.QRmode, "qr", true, "Insert qr or text") + } + if flag.Lookup("font") == nil { + flag.StringVar(¶ms.Font, "font", "./fonts/DroidSansMono.ttf", "Font for text") + } +} + +func main() { + flag.Parse() + + list, err := qrgen.PrepareData(data) + if err != nil { + log.Fatalf("Error in data preparation: %v", err) + } + params.BackgroundImg, err = qrgen.PrepareBackground(backgroundImg) + if err != nil { + log.Fatalf("Error: %v", err) + } + + params.Data = list + params.Preview = false + + err = qrgen.Generation(params) + if err != nil { + log.Fatalf("Error write file: %v", err) + } +} diff --git a/config/docker.yaml b/config/docker.yaml new file mode 100644 index 0000000..b52a56d --- /dev/null +++ b/config/docker.yaml @@ -0,0 +1,13 @@ +enviroment: "docker" + +timezone: "Europe/Moscow" + +qrGen: + outputDir: "output" + siteDir: "site" + tempDir: "temp" + +httpServer: + address: "qr-gen:10001" + timeout: 4s + idleTimeout: 60s \ No newline at end of file diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..c29d5a4 --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,13 @@ +enviroment: "local" + +timezone: "Europe/Moscow" + +qrGen: + outputDir: "output" + siteDir: "site" + tempDir: "temp" + +httpServer: + address: "localhost:10001" + timeout: 4s + idleTimeout: 60s \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27c2319 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + app: + build: . + container_name: qr-gen + volumes: + - ./config/:/app/config/ + - ./site/:/app/site/ + ports: + - "10001:10001" + environment: + CONFIG_PATH: "/app/config/docker.yaml" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c7bf46e --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/TOsmanov/qr-gen + +go 1.21 + +require ( + github.com/fogleman/gg v1.3.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/render v1.0.3 + github.com/go-playground/validator/v10 v10.20.0 + github.com/google/uuid v1.6.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/image v0.16.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fed958e --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +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/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +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/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..021d0c9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +package config + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/ilyakaznacheev/cleanenv" +) + +type Config struct { + Env string `yaml:"env" env-default:"local"` + TimeZone string `yaml:"timezone"` + QRGen `yaml:"qrGen"` + HTTPServer `yaml:"httpServer"` +} + +type HTTPServer struct { + Address string `yaml:"address" env-default:"localhost:8080"` + Timeout time.Duration `yaml:"timeout" env-default:"4s"` + IdleTimeout time.Duration `yaml:"idleTimeout" env-default:"60s"` + ShutdownTimeout time.Duration `yaml:"shutdownTimeout" env-default:"10s"` +} +type QRGen struct { + OutputDir string `yaml:"outputDir" env-default:"output"` + TempDir string `yaml:"tempDir" env-default:"temp"` + SiteDir string `yaml:"siteDir" env-default:"site"` + PreviewPath string + MainPage string +} + +func MustLoad() *Config { + configPath := getEnv("CONFIG_PATH", "./config/local.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + log.Fatalf("Config file does not exist: %s", configPath) + } + + var cfg Config + if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { + log.Fatalf("Cannot read config: %s", err) + } + cfg.PreviewPath = fmt.Sprintf("%s/preview.jpg", cfg.SiteDir) + cfg.MainPage = fmt.Sprintf("%s/index.html", cfg.SiteDir) + + return &cfg +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} diff --git a/internal/http-server/handlers/handlers.go b/internal/http-server/handlers/handlers.go new file mode 100644 index 0000000..8544fb3 --- /dev/null +++ b/internal/http-server/handlers/handlers.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "log/slog" + "net/http" + + "github.com/TOsmanov/qr-gen/internal/config" + "github.com/TOsmanov/qr-gen/internal/lib/api/response" +) + +type Response struct { + response.Response + Body any `json:"data,omitempty"` +} + +func IndexHandler(log *slog.Logger, cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + const op = "handlers.IndexHandler" + slog.Info(op) + Index(log, w, r, cfg) + } +} + +func BackgroundHandler(log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + const op = "handlers.BackgroundHandler" + slog.Info(op) + UploadBackground(log, w, r) + } +} + +func PreviewHandler(log *slog.Logger, cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + const op = "handlers.PreviewHandler" + slog.Info(op) + switch r.Method { + case http.MethodGet: + GetPreview(log, w, r, cfg) + case http.MethodPost: + PostPreview(log, w, r, cfg) + } + } +} + +func GenerationHandler(log *slog.Logger, cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + const op = "handlers.GenerationHandler" + slog.Info(op) + GenerationQR(log, w, r, cfg) + } +} diff --git a/internal/http-server/handlers/qrgen.go b/internal/http-server/handlers/qrgen.go new file mode 100644 index 0000000..401fcb2 --- /dev/null +++ b/internal/http-server/handlers/qrgen.go @@ -0,0 +1,208 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "image" + "io" + "log/slog" + "net/http" + "os" + + "github.com/TOsmanov/qr-gen/internal/config" + "github.com/TOsmanov/qr-gen/internal/lib/api/response" + qrgen "github.com/TOsmanov/qr-gen/qr-gen" + "github.com/go-chi/render" + "github.com/google/uuid" +) + +var static struct { + MainPage []byte + Preview []byte + Background image.Image +} + +func Index(log *slog.Logger, w http.ResponseWriter, + r *http.Request, cfg *config.Config, +) { + const op = "handlers.IndexHandler.Index" + if len(static.MainPage) == 0 { + var err error + static.MainPage, err = os.ReadFile(cfg.MainPage) + if err != nil { + w.WriteHeader(500) + log.Error( + "Failed to read main page", + op, err) + render.JSON(w, r, + response.Error("Failed to get main page")) + return + } + } + w.Write(static.MainPage) + log.Info("The main page has been sent successfully") +} + +func GetPreview(log *slog.Logger, w http.ResponseWriter, + r *http.Request, cfg *config.Config, +) { + const op = "handlers.PreviewHandler.GetPreview" + var err error + static.Preview, err = os.ReadFile(cfg.PreviewPath) + if err != nil { + w.WriteHeader(500) + log.Error( + "Failed to prepare background", + op, err) + render.JSON(w, r, + response.Error("Failed to get preview")) + return + } + + w.Header().Set("Content-Type", "image/jpg") + w.Write(static.Preview) + os.Remove(cfg.PreviewPath) +} + +func UploadBackground(log *slog.Logger, w http.ResponseWriter, + r *http.Request, +) { + const op = "handlers.BackgroundHandler.UploadBackground" + r.ParseMultipartForm(32 << 20) // 32 MB + file, _, err := r.FormFile("img") + if err != nil { + w.WriteHeader(400) + log.Error("Failed to read image from request", op, err) + responseFail(w, r, "Failed to read image from request") + return + } + defer file.Close() + + static.Background, _, err = image.Decode(file) + if err != nil { + w.WriteHeader(400) + log.Error("Failed to decode data", op, err) + responseFail(w, r, "Failed to decode data") + return + } + + log.Info("The background image has been uploaded successfully") + responseOK(w, r, "The background has been successfully upload") +} + +func PostPreview(log *slog.Logger, w http.ResponseWriter, + r *http.Request, cfg *config.Config, +) { + const op = "handlers.PreviewHandler.PostPreview" + var params qrgen.Params + + b, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(400) + log.Error("Failed to read request", op, err) + render.JSON(w, r, response.Error("Failed to read request")) + return + } + defer r.Body.Close() + + json.Unmarshal(b, ¶ms) + + params.BackgroundImg = static.Background + params.QRmode, params.Preview = true, true + params.Output = cfg.SiteDir + + err = qrgen.Generation(params) + if err != nil { + w.WriteHeader(500) + log.Error( + "Failed to generate preview", + op, err) + render.JSON(w, r, + response.Error("Failed to generate preview")) + return + } + responseOK(w, r, "The preview has been successfully generate") +} + +func GenerationQR(log *slog.Logger, w http.ResponseWriter, + r *http.Request, cfg *config.Config, +) { + const op = "handlers.GenerationHandler.Generation" + var params qrgen.Params + + // Parameters + b, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(400) + log.Error("Failed to read request", op, err) + render.JSON(w, r, response.Error("Failed to read request")) + return + } + defer r.Body.Close() + + json.Unmarshal(b, ¶ms) + + id := uuid.New() + params.BackgroundImg = static.Background + + // Make temp directory + params.Output = fmt.Sprintf("%s/%s", cfg.TempDir, id.String()) + err = os.MkdirAll(params.Output, os.ModePerm) + if err != nil { + w.WriteHeader(500) + log.Error("Failed to create temp directory", op, err) + render.JSON(w, r, response.Error("Failed to create temp directory")) + return + } + + // Generate images + params.QRmode, params.Preview = true, false + err = qrgen.Generation(params) + if err != nil { + w.WriteHeader(500) + log.Error( + "Failed to generation", + op, err) + render.JSON(w, r, + response.Error("Failed to generation")) + return + } + + // Archiving + outputZip := fmt.Sprintf("%s/%s.zip", cfg.SiteDir, id.String()) + qrgen.Archive(params.Output, outputZip) + + buf, err := os.ReadFile(outputZip) + if err != nil { + w.WriteHeader(500) + log.Error( + "Failed to read archive", + op, err) + render.JSON(w, r, + response.Error("Failed to get archive")) + return + } + w.Write(buf) + os.Remove(outputZip) +} + +func responseOK( + w http.ResponseWriter, + r *http.Request, + body string, +) { + render.JSON(w, r, Response{ + Response: response.OK(), + Body: body, + }) +} + +func responseFail( + w http.ResponseWriter, + r *http.Request, + msg string, +) { + render.JSON(w, r, Response{ + Response: response.Error(msg), + }) +} diff --git a/internal/http-server/handlers/qrgen_test.go b/internal/http-server/handlers/qrgen_test.go new file mode 100644 index 0000000..c2eea93 --- /dev/null +++ b/internal/http-server/handlers/qrgen_test.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "bytes" + "fmt" + "image/jpeg" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/TOsmanov/qr-gen/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestIndex(t *testing.T) { + validData := []byte("{}") + r := httptest.NewRequest(http.MethodGet, "/", bytes.NewBuffer(validData)) + w := httptest.NewRecorder() + log := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + cfg := config.Config{ + Env: "local", + } + cfg.MainPage = "../../../site/index.html" + Index(log, w, r, &cfg) + + assert.Equal(t, http.StatusOK, w.Code) + + b, err := os.ReadFile("../../../site/index.html") + assert.Nil(t, err) + expectedBody := string(b) + + assert.Equal(t, w.Body.String(), expectedBody) +} + +func TestGetPreview(t *testing.T) { + validData := []byte("{}") + r := httptest.NewRequest(http.MethodGet, "/preview", bytes.NewBuffer(validData)) + w := httptest.NewRecorder() + log := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + cfg := config.Config{ + Env: "local", + } + err := os.Link("../../../tests/expect-preview.jpg", "../../../site/preview.jpg") + assert.Nil(t, err) + + cfg.PreviewPath = "../../../site/preview.jpg" + GetPreview(log, w, r, &cfg) + + assert.Equal(t, http.StatusOK, w.Code) + + b, err := os.ReadFile("../../../tests/expect-preview.jpg") + assert.Nil(t, err) + expectedBody := string(b) + + assert.Equal(t, w.Body.String(), expectedBody) +} + +func TestUploadBackground(t *testing.T) { + t.Run("Test UploadBackground", func(t *testing.T) { + pipeReader, pipeWriter := io.Pipe() + multipartWriter := multipart.NewWriter(pipeWriter) + go func() { + defer multipartWriter.Close() + + fileField, err := multipartWriter.CreateFormFile("img", "background.jpg") + assert.Nil(t, err) + + fileBytes, err := os.ReadFile("../../../tests/background.jpg") + assert.Nil(t, err) + + reader := bytes.NewReader(fileBytes) + image, err := jpeg.Decode(reader) + assert.Nil(t, err) + + err = jpeg.Encode(fileField, image, &jpeg.Options{Quality: 80}) + assert.Nil(t, err) + + fmt.Println(fileField) + }() + r := httptest.NewRequest(http.MethodPost, "/background", pipeReader) + r.Header.Add("content-type", multipartWriter.FormDataContentType()) + w := httptest.NewRecorder() + log := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + + UploadBackground(log, w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + expectedBody := "{\"status\":\"OK\",\"data\":\"The background has been successfully upload\"}\n" + assert.Equal(t, w.Body.String(), expectedBody) + }) +} + +func TestPostPreview(t *testing.T) { + cfg := config.Config{ + Env: "local", + } + + TestUploadBackground(t) + + cfg.SiteDir = "../../site" + validData := []byte("{\"size\":120,\"hAlign\":50,\"vAlign\":70}") + + r := httptest.NewRequest(http.MethodPost, "/preview", bytes.NewBuffer(validData)) + w := httptest.NewRecorder() + log := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + PostPreview(log, w, r, &cfg) + + assert.Equal(t, http.StatusOK, w.Code) + + expectedBody := "{\"status\":\"OK\",\"data\":\"The preview has been successfully generate\"}\n" + + assert.Equal(t, expectedBody, w.Body.String()) +} + +func TestGenerationQR(t *testing.T) { + cfg := config.Config{ + Env: "local", + } + log := slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + + validData := []byte( + "{\"list\":[\"123\"],\"size\":120,\"hAlign\":50,\"vAlign\":75}") + cfg.TempDir = "../../../temp" + cfg.SiteDir = "../../../tmp-site" + err := os.MkdirAll(cfg.SiteDir, os.ModePerm) + assert.Nil(t, err) + + TestUploadBackground(t) + + r := httptest.NewRequest(http.MethodPost, "/generation", bytes.NewBuffer(validData)) + w := httptest.NewRecorder() + + GenerationQR(log, w, r, &cfg) + + assert.Equal(t, http.StatusOK, w.Code) + + expect, err := os.ReadFile("../../../tests/except-archive_2.zip") + assert.Nil(t, err) + + assert.Equal(t, len(expect), len(w.Body.Bytes())) + os.RemoveAll("../../../tmp-site") + os.RemoveAll("../../../temp") +} diff --git a/internal/http-server/middleware/logger/logger.go b/internal/http-server/middleware/logger/logger.go new file mode 100644 index 0000000..60e6409 --- /dev/null +++ b/internal/http-server/middleware/logger/logger.go @@ -0,0 +1,43 @@ +package logger + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +func New(log *slog.Logger, loc *time.Location) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + log = log.With( + slog.String("component", "middleware/logger"), + ) + + log.Info("logger middleware enabled") + + fn := func(w http.ResponseWriter, r *http.Request) { + entry := log.With( + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + slog.String("user_agent", r.UserAgent()), + ) + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + t1 := time.Now().In(loc) + + defer func() { + entry.Info("request completed", + slog.Int("status", ww.Status()), + slog.Int("bytes", ww.BytesWritten()), + slog.String("duration", time.Since(t1).String()), + ) + }() + + next.ServeHTTP(ww, r) + } + + return http.HandlerFunc(fn) + } +} diff --git a/internal/lib/api/response/response.go b/internal/lib/api/response/response.go new file mode 100644 index 0000000..9d37bde --- /dev/null +++ b/internal/lib/api/response/response.go @@ -0,0 +1,52 @@ +package response + +import ( + "fmt" + "strings" + + "github.com/go-playground/validator/v10" +) + +type Response struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + Body string `json:"body,omitempty"` +} + +const ( + StatusOK = "OK" + StatusError = "Error" +) + +func OK() Response { + return Response{ + Status: StatusOK, + } +} + +func Error(msg string) Response { + return Response{ + Status: StatusError, + Error: msg, + } +} + +func ValidationError(errs validator.ValidationErrors) Response { + errMsgs := make([]string, 0, len(errs)) + + for _, err := range errs { + switch err.ActualTag() { + case "required": + errMsgs = append(errMsgs, fmt.Sprintf("field %s is a required field", err.Field())) + case "url": + errMsgs = append(errMsgs, fmt.Sprintf("field %s is not a valid URL", err.Field())) + default: + errMsgs = append(errMsgs, fmt.Sprintf("field %s is not valid", err.Field())) + } + } + + return Response{ + Status: StatusError, + Error: strings.Join(errMsgs, ", "), + } +} diff --git a/internal/lib/logger/sl/sl.go b/internal/lib/logger/sl/sl.go new file mode 100644 index 0000000..ac19308 --- /dev/null +++ b/internal/lib/logger/sl/sl.go @@ -0,0 +1,10 @@ +package sl + +import "log/slog" + +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} diff --git a/internal/lib/utils/utils.go b/internal/lib/utils/utils.go new file mode 100644 index 0000000..0d3e2f9 --- /dev/null +++ b/internal/lib/utils/utils.go @@ -0,0 +1,21 @@ +package utils + +import ( + "crypto/sha256" + "fmt" + "io" + "os" +) + +func SumSha256(data []byte) string { + hash := sha256.Sum256(data) + return fmt.Sprintf("%x", hash) +} + +func FileSumSha256(f *os.File) (string, error) { + file1Sum := sha256.New() + if _, err := io.Copy(file1Sum, f); err != nil { + return "", err + } + return fmt.Sprintf("%X", file1Sum.Sum(nil)), nil +} diff --git a/internal/site/preview.jpg b/internal/site/preview.jpg new file mode 100644 index 0000000..ce16dd5 Binary files /dev/null and b/internal/site/preview.jpg differ diff --git a/qr-gen/qr-gen.go b/qr-gen/qr-gen.go new file mode 100644 index 0000000..85941fc --- /dev/null +++ b/qr-gen/qr-gen.go @@ -0,0 +1,102 @@ +package qrgen + +import ( + "fmt" + "image" + "image/draw" + "image/jpeg" + "os" + "regexp" + + "github.com/fogleman/gg" + "github.com/skip2/go-qrcode" +) + +type Params struct { + Data []string `json:"list,omitempty"` + Size int `json:"size" default:"120"` + HorizontalAlign int `json:"hAlign" default:"50"` + VerticalAlign int `json:"vAlign" default:"50"` + BackgroundImg image.Image + QRmode bool + Font string + Output string + Preview bool +} + +func Generation(params Params) error { + if (params.Size <= 0) && !(params.HorizontalAlign < 0) && !(params.VerticalAlign < 0) { + return fmt.Errorf("numeric parameters must be greater than zero") + } + hAlign := float64(params.HorizontalAlign) / 100 + vAlign := float64(params.VerticalAlign) / 100 + var err error + + if params.Preview { + params.Data = []string{ + "https://github.com/TOsmanov/qr-gen", + } + } + for _, data := range params.Data { + var upperImg image.Image + var filename string + if params.QRmode { + upperImg, err = prepareQR(params.Size, data) + if err != nil { + return err + } + } else { + upperImg, err = prepareText(params.Size, params.Font, data) + if err != nil { + return err + } + } + x := int(float64(params.BackgroundImg.Bounds().Dx())*hAlign) - params.Size/2 + y := int(float64(params.BackgroundImg.Bounds().Dy())*vAlign) - params.Size/2 + point := image.Point{-x, -y} + r := image.Rectangle{image.Point{0, 0}, params.BackgroundImg.Bounds().Max} + rgba := image.NewRGBA(r) + draw.Draw(rgba, params.BackgroundImg.Bounds(), params.BackgroundImg, image.Point{0, 0}, draw.Src) + draw.Draw(rgba, params.BackgroundImg.Bounds(), upperImg, point, draw.Src) + os.Mkdir(params.Output, 0o750) + if params.Preview { + filename = "preview" + } else { + regex := regexp.MustCompile(`[htps]*://|/|\\|\s`) + filename = regex.ReplaceAllString(data, "") + } + var out *os.File + out, err = os.Create(fmt.Sprintf("%s/%s.jpg", params.Output, filename)) + if err != nil { + return err + } + + var opt jpeg.Options + opt.Quality = 80 + + jpeg.Encode(out, rgba, &opt) + } + return nil +} + +func prepareQR(qrSize int, data string) (image.Image, error) { + qr, err := qrcode.New(data, qrcode.Medium) + if err != nil { + return nil, err + } + qrImg := qr.Image(qrSize) + return qrImg, nil +} + +func prepareText(size int, font string, data string) (image.Image, error) { + fontSize := (float64(size) / float64(len([]rune(data)))) * 1.5 + dc := gg.NewContext(size, int(fontSize*1.4)) + dc.SetRGB(1, 1, 1) + dc.Clear() + dc.SetRGB(0, 0, 0) + if err := dc.LoadFontFace(font, fontSize); err != nil { + return nil, err + } + dc.DrawStringAnchored(data, float64(size/2), fontSize*1.3/2, 0.5, 0.5) + return dc.Image(), nil +} diff --git a/qr-gen/qr-gen_test.go b/qr-gen/qr-gen_test.go new file mode 100644 index 0000000..ed53aed --- /dev/null +++ b/qr-gen/qr-gen_test.go @@ -0,0 +1,196 @@ +package qrgen + +import ( + "os" + "testing" + + "github.com/TOsmanov/qr-gen/internal/lib/utils" + "github.com/stretchr/testify/assert" +) + +const ext = ".jpg" + +func TestGenerationQR(t *testing.T) { + list, err := PrepareData("../tests/data.txt") + assert.Nil(t, err) + img, err := PrepareBackground("../tests/background.jpg") + assert.Nil(t, err) + + params := Params{ + Data: list, + Size: 120, + QRmode: true, + BackgroundImg: img, + HorizontalAlign: 50, + VerticalAlign: 75, + Output: "../tests/output/", + Preview: false, + } + + err = Generation(params) + assert.Nil(t, err) + + // Comparing the number of files in a folder + output, err := os.ReadDir("../tests/output/") + assert.Nil(t, err) + + expectOutput, err := os.ReadDir("../tests/output/") + assert.Nil(t, err) + assert.Equal(t, len(output), len(expectOutput)) + + // Comparing sums + for _, file := range list { + // Output + var f1 *os.File + f1, err = os.Open("../tests/output/" + file + ext) + assert.Nil(t, err) + + f1.Seek(0, 0) + var sum1 string + sum1, err = utils.FileSumSha256(f1) + assert.Nil(t, err) + + // Expected output + var f2 *os.File + f2, err = os.Open("../tests/expect-output/" + file + ext) + assert.Nil(t, err) + + f2.Seek(0, 0) + var sum2 string + sum2, err = utils.FileSumSha256(f2) + assert.Nil(t, err) + + // Compare + assert.Equal(t, sum2, sum1) + } + + if err == nil { + os.RemoveAll("../tests/output/") + } +} + +func TestGenerationQRPreview(t *testing.T) { + img, err := PrepareBackground("../tests/background.jpg") + assert.Nil(t, err) + + params := Params{ + Size: 120, + QRmode: true, + BackgroundImg: img, + HorizontalAlign: 50, + VerticalAlign: 75, + Output: "../tests/", + Preview: true, + } + + err = Generation(params) + assert.Nil(t, err) + + // Output + var f1 *os.File + f1, err = os.Open("../tests/preview.jpg") + assert.Nil(t, err) + + f1.Seek(0, 0) + var sum1 string + sum1, err = utils.FileSumSha256(f1) + assert.Nil(t, err) + + // Expected output + var f2 *os.File + f2, err = os.Open("../tests/expect-preview.jpg") + assert.Nil(t, err) + + f2.Seek(0, 0) + var sum2 string + sum2, err = utils.FileSumSha256(f2) + assert.Nil(t, err) + + // Compare + assert.Equal(t, sum2, sum1) + + if err == nil { + os.Remove("../tests/preview.jpg") + } +} + +func TestGenerationQRErrors(t *testing.T) { + list, err := PrepareData("../tests/data.txt") + assert.Nil(t, err) + img, err := PrepareBackground("../tests/background.jpg") + assert.Nil(t, err) + + params := Params{ + Data: list, + Size: -1, + QRmode: true, + BackgroundImg: img, + HorizontalAlign: 50, + VerticalAlign: 75, + Output: "../tests/output/", + Preview: false, + } + + err = Generation(params) + assert.NotNil(t, err) +} + +func TestGenerationText(t *testing.T) { + list, err := PrepareData("../tests/data.txt") + assert.Nil(t, err) + img, err := PrepareBackground("../tests/background.jpg") + assert.Nil(t, err) + + params := Params{ + Data: list, + Size: 120, + QRmode: false, + BackgroundImg: img, + HorizontalAlign: 50, + VerticalAlign: 75, + Font: "../tests/font/DroidSansMono.ttf", + Output: "../tests/output/", + Preview: false, + } + + err = Generation(params) + assert.Nil(t, err) + + // Comparing the number of files in a folder + output, err := os.ReadDir("../tests/output/") + assert.Nil(t, err) + + expectOutput, err := os.ReadDir("../tests/output/") + assert.Nil(t, err) + assert.Equal(t, len(output), len(expectOutput)) + + // Comparing sums + for _, file := range list { + // Output + var f1 *os.File + f1, err = os.Open("../tests/output/" + file + ext) + assert.Nil(t, err) + + f1.Seek(0, 0) + var sum1 string + sum1, err = utils.FileSumSha256(f1) + assert.Nil(t, err) + + // Expected output + var f2 *os.File + f2, err = os.Open("../tests/expect-output-text/" + file + ext) + assert.Nil(t, err) + + f2.Seek(0, 0) + var sum2 string + sum2, err = utils.FileSumSha256(f2) + assert.Nil(t, err) + + // Compare + assert.Equal(t, sum2, sum1) + } + + if err == nil { + os.RemoveAll("../tests/output/") + } +} diff --git a/qr-gen/reaader_test.go b/qr-gen/reaader_test.go new file mode 100644 index 0000000..5c63b87 --- /dev/null +++ b/qr-gen/reaader_test.go @@ -0,0 +1,43 @@ +package qrgen + +import ( + "image" + "image/color" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrepareData(t *testing.T) { + var list []string + var err error + _, err = PrepareData("bad/path.txt") + assert.NotNil(t, err) + list, err = PrepareData("../tests/data.txt") + assert.Nil(t, err) + expected := []string{ + "12300534643", + "4735786789", + "FHKR759Skl6378993", + "BRGJHMOW525", + "QWE12GU", + "QWE12", + "QWE", + "QW", + } + assert.Equal(t, list, expected) +} + +func TestPrepareBackground(t *testing.T) { + var img image.Image + var err error + _, err = PrepareBackground("bad/img.jpg") + assert.NotNil(t, err) + img, err = PrepareBackground("../tests/background.jpg") + assert.Nil(t, err) + // Test random pixels + assert.Equal(t, color.YCbCr{Y: 0x1e, Cb: 0x87, Cr: 0x7d}, img.At(0, 0)) + assert.Equal(t, color.YCbCr{Y: 0xa7, Cb: 0x87, Cr: 0x7d}, img.At(5, 5)) + assert.Equal(t, color.YCbCr{Y: 0x12, Cb: 0x7d, Cr: 0x81}, img.At(30, 50)) + assert.Equal(t, color.YCbCr{Y: 0xbb, Cb: 0x88, Cr: 0x7a}, img.At(100, 162)) +} diff --git a/qr-gen/reader.go b/qr-gen/reader.go new file mode 100644 index 0000000..0f77754 --- /dev/null +++ b/qr-gen/reader.go @@ -0,0 +1,34 @@ +package qrgen + +import ( + "bufio" + "image" + "os" +) + +func PrepareBackground(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + img, _, err := image.Decode(file) + if err != nil { + return nil, err + } + return img, nil +} + +func PrepareData(path string) ([]string, error) { + var list []string + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + list = append(list, line) + } + return list, nil +} diff --git a/qr-gen/writer.go b/qr-gen/writer.go new file mode 100644 index 0000000..6939235 --- /dev/null +++ b/qr-gen/writer.go @@ -0,0 +1,51 @@ +package qrgen + +import ( + "archive/zip" + "io" + "os" + "path/filepath" +) + +func Archive(inputFolder string, outputPath string) error { + archive, err := os.Create(outputPath) + if err != nil { + return err + } + defer archive.Close() + + w := zip.NewWriter(archive) + defer w.Close() + + walker := func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + var file *os.File + file, err = os.Open(path) + if err != nil { + return err + } + defer file.Close() + + filename := filepath.Base(path) + f, err := w.Create(filename) + if err != nil { + return err + } + + _, err = io.Copy(f, file) + if err != nil { + return err + } + return nil + } + err = filepath.Walk(inputFolder, walker) + if err != nil { + return err + } + return nil +} diff --git a/qr-gen/writer_test.go b/qr-gen/writer_test.go new file mode 100644 index 0000000..a5ffc67 --- /dev/null +++ b/qr-gen/writer_test.go @@ -0,0 +1,47 @@ +package qrgen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/TOsmanov/qr-gen/internal/lib/utils" + "github.com/stretchr/testify/assert" +) + +func TestArchive(t *testing.T) { + inputAbsPath, err := filepath.Abs("../tests/expect-output") + assert.Nil(t, err) + + outputPath := "../tests/archive.zip" + expectOutputPath := "../tests/except-archive.zip" + + err = Archive(inputAbsPath, outputPath) + assert.Nil(t, err) + + // Output archive + var f1 *os.File + f1, err = os.Open(outputPath) + assert.Nil(t, err) + + f1.Seek(0, 0) + var sum1 string + sum1, err = utils.FileSumSha256(f1) + assert.Nil(t, err) + + // Expected archive + var f2 *os.File + f2, err = os.Open(expectOutputPath) + assert.Nil(t, err) + + f2.Seek(0, 0) + var sum2 string + sum2, err = utils.FileSumSha256(f2) + assert.Nil(t, err) + + // Compare + assert.Equal(t, sum2, sum1) + if err == nil { + os.Remove("../tests/archive.zip") + } +} diff --git a/run_server.sh b/run_server.sh new file mode 100644 index 0000000..5d9a2bf --- /dev/null +++ b/run_server.sh @@ -0,0 +1,4 @@ +#!/bin/bash -x + +# Run the HTTP server +go run ./server >> ./log.txt \ No newline at end of file diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..efe36f0 --- /dev/null +++ b/server/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/TOsmanov/qr-gen/internal/config" + "github.com/TOsmanov/qr-gen/internal/http-server/handlers" + mwLogger "github.com/TOsmanov/qr-gen/internal/http-server/middleware/logger" + "github.com/TOsmanov/qr-gen/internal/lib/logger/sl" + chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + var err error + cfg := config.MustLoad() + + log := SetupLogger(cfg.Env) + + log.Info("Starting qr-generation service", slog.String("env", cfg.Env)) + log.Debug("DEBUG messages are enabled", slog.String("env", cfg.Env)) + + router := chi.NewRouter() + + loc, err := time.LoadLocation(cfg.TimeZone) + if err != nil { + loc = time.FixedZone("UTC-8", -8*60*60) + log.Error("Error load location, used UTC-8", slog.String("time-zone", cfg.TimeZone)) + } + + router.Use(mwLogger.New(log, loc)) + router.Use(middleware.Recoverer) + router.Use(middleware.URLFormat) + + router.HandleFunc("/", handlers.IndexHandler(log, cfg)) + router.HandleFunc("/background", handlers.BackgroundHandler(log)) + router.HandleFunc("/preview", handlers.PreviewHandler(log, cfg)) + router.HandleFunc("/generation", handlers.GenerationHandler(log, cfg)) + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + srv := &http.Server{ + Addr: cfg.Address, + Handler: router, + ReadTimeout: cfg.HTTPServer.Timeout, + WriteTimeout: cfg.HTTPServer.Timeout, + IdleTimeout: cfg.HTTPServer.IdleTimeout, + } + + log.Info("Starting server", slog.String("address", srv.Addr)) + + go func() { + if err = srv.ListenAndServe(); err != nil { + log.Error("Failed to serve server", sl.Err(err)) + srv.Close() + } + }() + + log.Info("Server started") + + <-done + log.Info("Stopping server") + + Clean(cfg) + log.Info("Server stopped") +} + +func SetupLogger(env string) *slog.Logger { + var log *slog.Logger + switch env { + case "local": + log = slog.New( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + default: + log = slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), + ) + } + + return log +} + +func Clean(cfg *config.Config) { + os.RemoveAll(cfg.TempDir) + os.RemoveAll(cfg.OutputDir) + os.Remove(cfg.PreviewPath) +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..975f0d9 --- /dev/null +++ b/site/index.html @@ -0,0 +1,272 @@ + + +
+ + + + +There will be a preview of the image here