28 Commits
1.2.6 ... 1.5.2

Author SHA1 Message Date
f902c61490 feat(certificate): update footer image
All checks were successful
Build release images / build-container (push) Successful in 2m13s
Build latest image / build-container (push) Successful in 2m19s
2025-04-23 11:57:44 +02:00
11e8cc5b1d feat(certificate): Add SepaConfig to certificate generation and CombinedGroupName to runners
All checks were successful
Build release images / build-container (push) Successful in 1m57s
Build latest image / build-container (push) Successful in 2m22s
2025-04-17 22:22:50 +02:00
84155b7404 feat(templater): Add GenerateEPC method for generating EPC QR codes 2025-04-17 22:22:46 +02:00
45b37197ec feat(models): Add CombinedGroupName to RunnerWithDonations and introduce SepaConfig struct 2025-04-17 22:22:43 +02:00
f65848924c feat(templates): Update donation transfer text and add SEPA support in certificate templates 2025-04-17 22:22:37 +02:00
98d584867e feat(config): Add SEPA fields to environment configuration 2025-04-17 22:22:29 +02:00
376e8de1a4 feat(config): Add CurrencyIdentifier to configuration 2025-04-17 22:01:02 +02:00
2911391fb9 feat(config): Add SEPA fields to configuration 2025-04-17 21:51:27 +02:00
6d2e0241c9 feat(templates): Added selfservice qr
All checks were successful
Build release images / build-container (push) Successful in 2m12s
Build latest image / build-container (push) Successful in 2m26s
2025-04-17 21:44:58 +02:00
afc5b1f0c6 fix(models): Add required SelfServiceLink field to RunnerWithDonations struct 2025-04-17 21:43:54 +02:00
4a76ee469b fix(barcode): Use auto encoding for QR code generation to support all characters 2025-04-17 21:43:44 +02:00
b58bf700df chore(static) Add base64 encoded image for new sponsors
All checks were successful
Build latest image / build-container (push) Successful in 2m2s
Build release images / build-container (push) Successful in 2m6s
2025-04-14 18:08:03 +02:00
efd3a35802 fix(templates): Update titles for runner and certificate templates 2025-04-14 17:56:49 +02:00
0f7e44a42a fix(models): Correct typo in SponsoringReceiptMinimum mapstructure tag
All checks were successful
Build latest image / build-container (push) Successful in 2m6s
Build release images / build-container (push) Successful in 2m17s
2025-04-10 15:29:29 +02:00
f90e5d75fa fix(contracts): Minimum was not read correctly
All checks were successful
Build release images / build-container (push) Successful in 2m17s
Build latest image / build-container (push) Successful in 2m27s
2025-04-10 15:16:22 +02:00
31d4ec5f27 fix(templates): Correct spacing in group name display
All checks were successful
Build release images / build-container (push) Successful in 1m51s
Build latest image / build-container (push) Successful in 2m13s
2025-03-26 19:29:32 +01:00
d61d4d6e7e refactor(ci): Switch to actions
All checks were successful
Build latest image / build-container (push) Successful in 1m39s
2025-03-22 22:48:35 +01:00
606ce6b940 docs(swagger): Build new docs
Some checks failed
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/push/build Pipeline failed
2024-12-17 17:51:51 +01:00
750fa70332 feat(models): Support nested groups 2024-12-17 17:51:11 +01:00
7d503edbc9 feat(templates): Support nested groups 2024-12-17 17:50:54 +01:00
5c9235df8d fix(templates): Enable blank cards 2024-12-17 17:38:42 +01:00
11ea0858bb feat(services): Logging
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
2024-12-17 16:23:52 +01:00
4d57cf827d refactor(handler): Move array manipulation
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2024-12-17 16:07:40 +01:00
df9f7fdc13 feat(handlers): Added info logging 2024-12-17 16:07:02 +01:00
cdd2b5e250 feat(logging): Debug logging 2024-12-17 15:48:12 +01:00
94b766f106 feat(logger): Log levels 2024-12-17 15:45:40 +01:00
a2e94f715b refactor(logs): Replaced main logger with zap
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2024-12-16 17:31:23 +01:00
f64daaf817 feat: Request-IDs for better debugging 2024-12-16 17:23:58 +01:00
32 changed files with 401 additions and 88 deletions

9
.env
View File

@@ -1,3 +1,4 @@
LOGLEVEL=debug
PORT=3000
PRODUCION=false
APIKEY=lfk
@@ -10,9 +11,13 @@ CARD_SUBTITLE=Kaya ist cool
CARD_BARCODEFORMAT=ean13
# CARD_BARCODEPREFIX=
SPONSOING_RECEIPTMINIMUM=10
SPONSORING_RECEIPTMINIMUM=40
SPONSORING_DISCLAIMER=Kaya ist cool, aber pass auf, dass du nicht zu viel Geld sammelst!
SPONSORING_BARCODEFORMAT=code128
# SPONSORING_BARCODEPREFIX=
CERTIFICATE_FOOTER=Kaya ist cool, danke für deine Unterstützung!
CERTIFICATE_FOOTER=Kaya ist cool, danke für deine Unterstützung!
SEPA_BIC=FNOMDEB2
SEPA_NAME=ODIT.Services
SEPA_IBAN=DE25100180000690238989

27
.gitea/workflows/dev.yaml Normal file
View File

@@ -0,0 +1,27 @@
name: Build latest image
on:
push:
branches:
- main
jobs:
build-container:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with:
registry: registry.odit.services
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ vars.REGISTRY }}/lfk/document-server:latest
platforms: linux/amd64,linux/arm64

View File

@@ -0,0 +1,27 @@
name: Build release images
on:
push:
tags:
- "*.*.*"
jobs:
build-container:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with:
registry: registry.odit.services
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ vars.REGISTRY }}/lfk/document-server:${{ github.ref_name }}
platforms: linux/amd64,linux/arm64

View File

@@ -1,18 +0,0 @@
steps:
- name: build latest
image: woodpeckerci/plugin-docker-buildx
settings:
repo: registry.odit.services/lfk/document-server
tags:
- latest
registry: registry.odit.services
platforms: linux/amd64,linux/arm64
cache_from: registry.odit.services/lfk/document-server:latest
username:
from_secret: odit-registry-builder-username
password:
from_secret: odit-registry-builder-password
when:
branch: main
when:
event: push

View File

@@ -1,17 +0,0 @@
steps:
- name: build tag
image: woodpeckerci/plugin-docker-buildx
settings:
repo: registry.odit.services/lfk/document-server
tags:
- "${CI_COMMIT_TAG}"
registry: registry.odit.services
platforms: linux/amd64,linux/arm64
cache_from: registry.odit.services/lfk/document-server:latest
username:
from_secret: odit-registry-builder-username
password:
from_secret: odit-registry-builder-password
when:
event:
- tag

View File

@@ -326,6 +326,17 @@ const docTemplate = `{
"properties": {
"name": {
"type": "string"
},
"parent_group": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
}
}
},

View File

@@ -317,6 +317,17 @@
"properties": {
"name": {
"type": "string"
},
"parent_group": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
}
}
},

View File

@@ -96,6 +96,13 @@ definitions:
properties:
name:
type: string
parent_group:
properties:
name:
type: string
required:
- name
type: object
required:
- name
type: object

3
go.mod
View File

@@ -49,7 +49,8 @@ require (
github.com/valyala/fasthttp v1.57.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.19.0 // indirect

4
go.sum
View File

@@ -107,6 +107,10 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=

View File

@@ -20,6 +20,8 @@ import (
// @Router /v1/barcodes/{type}/{content} [get]
func (h *DefaultHandler) GenerateBarcode(c *fiber.Ctx) error {
logger := h.Logger.Named("GenerateBarcode")
// Get the type and content from the URL
barcodeType := c.Params("type")
barcodeContent := c.Params("content")
@@ -32,6 +34,7 @@ func (h *DefaultHandler) GenerateBarcode(c *fiber.Ctx) error {
// Convert width and height to integers
width, err := strconv.Atoi(widthStr)
if err != nil {
logger.Errorw("Invalid width parameter", "width", widthStr, "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid width parameter",
})
@@ -39,6 +42,7 @@ func (h *DefaultHandler) GenerateBarcode(c *fiber.Ctx) error {
height, err := strconv.Atoi(heightStr)
if err != nil {
logger.Errorw("Invalid height parameter", "height", heightStr, "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid height parameter",
})
@@ -46,18 +50,23 @@ func (h *DefaultHandler) GenerateBarcode(c *fiber.Ctx) error {
padding, err := strconv.Atoi(paddingStr)
if err != nil {
logger.Errorw("Invalid padding parameter", "padding", paddingStr, "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid padding parameter",
})
}
logger = logger.With("type", barcodeType, "content", barcodeContent, "width", width, "height", height, "padding", padding)
// Generate the barcode
logger.Info("Generating barcode")
barcode, err := h.BarcodeService.GenerateBarcode(barcodeType, barcodeContent, width, height, padding)
if err != nil {
logger.Errorw("Failed to generate barcode", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Barcode generated")
c.Set(fiber.HeaderContentType, "image/png")
return c.Send(barcode.Bytes())

View File

@@ -1,7 +1,6 @@
package handlers
import (
"log"
"slices"
"git.odit.services/lfk/document-server/models"
@@ -19,27 +18,35 @@ import (
// @Security ApiKeyAuth
// @Router /v1/pdfs/cards [post]
func (h *DefaultHandler) GenerateCard(c *fiber.Ctx) error {
logger := h.Logger.Named("GenerateCard")
cardRequest := new(models.CardRequest)
if err := c.BodyParser(cardRequest); err != nil {
logger.Errorw("Invalid request", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": err.Error(),
})
}
if !slices.Contains([]string{"en", "de"}, cardRequest.Locale) {
logger.Errorw("Invalid locale", "locale", cardRequest.Locale)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid locale",
})
}
logger = logger.With("locale", cardRequest.Locale)
templateString, err := h.StaticService.GetTemplate(cardRequest.Locale, "card")
if err != nil {
log.Println(err)
logger.Errorw("Template not found", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Template not found",
})
}
template, err := h.Templater.StringToTemplate(templateString)
if err != nil {
logger.Errorw("Error parsing template", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
@@ -53,20 +60,26 @@ func (h *DefaultHandler) GenerateCard(c *fiber.Ctx) error {
BarcodePrefix: h.Config.CardBarcodePrefix,
}
logger.Info("Generating card html")
result, err := h.Templater.Execute(template, genConfig)
if err != nil {
logger.Errorw("Error executing template", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Generated card html")
c.Set(fiber.HeaderContentType, "text/html")
logger.Info("Converting html to pdf")
pdf, err := h.Converter.ToPdf(result, "a4", false)
if err != nil {
logger.Errorw("Error converting html to pdf", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Converted html to pdf")
c.Set(fiber.HeaderContentType, "application/pdf")
c.Set(fiber.HeaderContentDisposition, "attachment; filename=runner-cards.pdf")

View File

@@ -1,7 +1,6 @@
package handlers
import (
"log"
"slices"
"git.odit.services/lfk/document-server/models"
@@ -19,27 +18,35 @@ import (
// @Security ApiKeyAuth
// @Router /v1/pdfs/certificates [post]
func (h *DefaultHandler) GenerateCertificate(c *fiber.Ctx) error {
logger := h.Logger.Named("GenerateCertificate")
certificateRequest := new(models.CertificateRequest)
if err := c.BodyParser(certificateRequest); err != nil {
logger.Errorw("Invalid request", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": err.Error(),
})
}
if !slices.Contains([]string{"en", "de"}, certificateRequest.Locale) {
logger.Errorw("Invalid locale", "locale", certificateRequest.Locale)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid locale",
})
}
logger = logger.With("locale", certificateRequest.Locale)
templateString, err := h.StaticService.GetTemplate(certificateRequest.Locale, "certificate")
if err != nil {
log.Println(err)
logger.Errorw("Template not found", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Template not found",
})
}
template, err := h.Templater.StringToTemplate(templateString)
if err != nil {
logger.Errorw("Error parsing template", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
@@ -51,22 +58,32 @@ func (h *DefaultHandler) GenerateCertificate(c *fiber.Ctx) error {
Footer: h.Config.CertificateFooter,
CurrencySymbol: h.Config.CurrencySymbol,
Locale: certificateRequest.Locale,
SepaConfig: &models.SepaConfig{
BIC: h.Config.SepaBic,
HolderName: h.Config.SepaName,
IBAN: h.Config.SepaIban,
CurrencyIdentifier: h.Config.CurrencyIdentifier,
},
}
logger.Info("Generating certificate html")
result, err := h.Templater.Execute(template, genConfig)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Generated card html")
c.Set(fiber.HeaderContentType, "text/html")
logger.Info("Converting html to pdf")
pdf, err := h.Converter.ToPdf(result, "a4", false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Converted html to pdf")
c.Set(fiber.HeaderContentType, "application/pdf")
c.Set(fiber.HeaderContentDisposition, "attachment; filename=certificate.pdf")
@@ -79,6 +96,13 @@ func addUpRunnerDonations(runners []models.RunnerWithDonations) []models.RunnerW
runners[i].TotalDonations += runners[i].DistanceDonations[j].Amount
runners[i].TotalPerDistance += runners[i].DistanceDonations[j].AmountPerDistance
}
if runners[i].Group.ParentGroup.Name != "" {
runners[i].CombinedGroupName = runners[i].Group.ParentGroup.Name + " - " + runners[i].Group.Name
} else {
runners[i].CombinedGroupName = runners[i].Group.Name
}
}
return runners
}

View File

@@ -1,7 +1,6 @@
package handlers
import (
"log"
"slices"
"git.odit.services/lfk/document-server/models"
@@ -19,36 +18,42 @@ import (
// @Security ApiKeyAuth
// @Router /v1/pdfs/contracts [post]
func (h *DefaultHandler) GenerateContract(c *fiber.Ctx) error {
logger := h.Logger.Named("GenerateContract")
contract := new(models.ContractRequest)
if err := c.BodyParser(contract); err != nil {
logger.Errorw("Invalid request", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": err.Error(),
})
}
if !slices.Contains([]string{"en", "de"}, contract.Locale) {
logger.Errorw("Invalid locale", "locale", contract.Locale)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid locale",
})
}
contract.Runners = repeatRunnerArrayItems(contract.Runners, 2)
logger = logger.With("locale", contract.Locale)
templateString, err := h.StaticService.GetTemplate(contract.Locale, "contract")
if err != nil {
log.Println(err)
logger.Errorw("Template not found", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Template not found",
})
}
template, err := h.Templater.StringToTemplate(templateString)
if err != nil {
logger.Errorw("Error parsing template", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
genConfig := &models.ContractTemplateOptions{
Runners: contract.Runners,
Runners: repeatRunnerArrayItems(contract.Runners, 2),
CurrencySymbol: h.Config.CurrencySymbol,
Disclaimer: h.Config.SponosringDisclaimer,
ReceiptMinimumAmount: h.Config.SponsoringReceiptMinimum,
@@ -57,19 +62,23 @@ func (h *DefaultHandler) GenerateContract(c *fiber.Ctx) error {
BarcodePrefix: h.Config.SponsoringBarcodePrefix,
}
logger.Info("Generating contract html")
result, err := h.Templater.Execute(template, genConfig)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Generated contract html")
logger.Info("Converting html to pdf")
pdf, err := h.Converter.ToPdf(result, "a5", true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
logger.Info("Converted html to pdf")
c.Set(fiber.HeaderContentType, "application/pdf")
c.Set(fiber.HeaderContentDisposition, "attachment; filename=runner-contracts.pdf")

View File

@@ -4,6 +4,7 @@ import (
"git.odit.services/lfk/document-server/models"
"git.odit.services/lfk/document-server/services"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
type Handler interface {
@@ -19,4 +20,5 @@ type DefaultHandler struct {
Templater services.Templater
Converter services.Converter
StaticService services.StaticService
Logger *zap.SugaredLogger
}

71
main.go
View File

@@ -3,7 +3,8 @@ package main
import (
"crypto/sha256"
"crypto/subtle"
"log"
"os"
"strings"
"git.odit.services/lfk/document-server/docs" // Correct import path for docs
"git.odit.services/lfk/document-server/handlers"
@@ -12,13 +13,17 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/keyauth"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/gofiber/swagger"
"github.com/redis/go-redis/v9"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var (
config *models.Config
logger *zap.SugaredLogger
)
func validateAPIKey(c *fiber.Ctx, key string) (bool, error) {
@@ -33,11 +38,13 @@ func validateAPIKey(c *fiber.Ctx, key string) (bool, error) {
func loadEnv() error {
viper.SetDefault("PRODUCION", true)
viper.SetDefault("LOGLEVEL", "INFO")
viper.SetDefault("PRODUCION", false)
viper.SetDefault("PORT", "3000")
viper.SetDefault("APIKEY", "lfk")
viper.SetDefault("EVENTNAME", "Demo Event")
viper.SetDefault("CURRENCYSYMBOL", "€")
viper.SetDefault("CURRENCYIDENTIFIER", "EUR")
viper.SetDefault("CARD_SUBTITLE", "Runner Card")
viper.SetDefault("CARD_BARCODEFORMAT", "ean13")
viper.SetDefault("CARD_BARCODEPREFIX", "")
@@ -48,6 +55,9 @@ func loadEnv() error {
viper.SetDefault("CERTIFICATE_FOOTER", "Footer")
viper.SetDefault("GOTENBERG_BASEURL", "")
viper.SetDefault("REDIS_ADDR", "")
viper.SetDefault("SEPA_BIC", "")
viper.SetDefault("SEPA_NAME", "")
viper.SetDefault("SEPA_IBAN", "")
// Load .env file
viper.SetConfigFile(".env")
@@ -56,7 +66,7 @@ func loadEnv() error {
viper.AutomaticEnv()
err := viper.ReadInConfig()
if err != nil {
log.Println("No .env file found")
logger.Warn("No .env file found")
}
// Unmarshal the config from file and env into the config struct
@@ -65,7 +75,34 @@ func loadEnv() error {
return err
}
log.Printf("Loaded config: %+v\n", config)
logger.Infow("Loaded config", "config", &config)
return nil
}
func initLogger() error {
logLevel := os.Getenv("LOGLEVEL")
if logLevel == "" {
logLevel = "INFO"
}
var zapLogLevel zapcore.Level
err := zapLogLevel.UnmarshalText([]byte(strings.ToLower(logLevel)))
if err != nil {
zapLogLevel = zapcore.InfoLevel
}
zapConfig := zap.NewProductionConfig()
zapConfig.Level = zap.NewAtomicLevelAt(zapLogLevel)
zapLogger, err := zapConfig.Build()
if err != nil {
return err
}
defer zapLogger.Sync()
logger = zapLogger.Sugar()
logger.Debug("Initialized logger")
return nil
}
@@ -82,14 +119,21 @@ func loadEnv() error {
// @name key
func main() {
err := loadEnv()
// Init the logger
err := initLogger()
if err != nil {
log.Fatal(err)
return
}
err = loadEnv()
if err != nil {
logger.Error(err)
return
}
var redisClient *redis.Client
if config.RedisAddr != "" {
log.Println("Using redis at", config.RedisAddr)
logger.Infow("Using redis", "redisAddr", config.RedisAddr)
redisClient = redis.NewClient(&redis.Options{
Addr: config.RedisAddr,
})
@@ -97,9 +141,11 @@ func main() {
barcodeGenerator := &services.DefaultBarcodeService{
RedisClient: redisClient,
Logger: logger.Named("DefaultBarcodeService"),
}
staticService := &services.DefaultStaticService{
Cache: make(map[string]string),
Cache: make(map[string]string),
Logger: logger.Named("DefaultStaticService"),
}
handler := handlers.DefaultHandler{
Config: config,
@@ -108,11 +154,15 @@ func main() {
Templater: &services.DefaultTemplater{
BarcodeService: barcodeGenerator,
StaticService: staticService,
Logger: logger.Named("DefaultTemplater"),
},
Converter: &services.GotenbergConverter{
BaseUrl: config.GotenbergBaseUrl,
Logger: logger.Named("GotenbergConverter"),
},
Logger: logger.Named("DefaultHandler"),
}
logger.Debug("Initialized services")
// Create a new Fiber instance
app := fiber.New(fiber.Config{
@@ -120,6 +170,7 @@ func main() {
})
app.Use(cors.New())
app.Use(requestid.New())
// Swagger documentation route
app.Get("/swagger/*", swagger.HandlerDefault)
@@ -137,9 +188,11 @@ func main() {
pdfv1.Post("/certificates", handler.GenerateCertificate)
v1.Get("/barcodes/:type/:content", handler.GenerateBarcode)
logger.Debug("Initialized routes")
app.Use(handler.NotFoundHandler)
docs.SwaggerInfo.BasePath = "/"
log.Fatal(app.Listen("0.0.0.0:" + config.Port))
logger.Infow("Starting server", "port", config.Port)
logger.Error(app.Listen("0.0.0.0:" + config.Port))
}

View File

@@ -11,10 +11,12 @@ type RunnerWithDonations struct {
MiddleName string `json:"middle_name" validate:"optional"`
LastName string `json:"last_name" validate:"required"`
Group Group `json:"group" validate:"required"`
CombinedGroupName string `json:"combined_group_name" validate:"optional"`
Distance int `json:"distance" validate:"required"`
DistanceDonations []DistanceDonation `json:"distance_donations" validate:"optional"`
TotalPerDistance int `json:"total_per_distance" validate:"optional"`
TotalDonations int `json:"total_donations" validate:"optional"`
SelfServiceLink string `json:"self_service_link" validate:"required"`
}
type DistanceDonation struct {
@@ -38,4 +40,12 @@ type CertificateTemplateOptions struct {
Footer string `json:"footer"`
CurrencySymbol string `json:"currency_symbol"`
Locale string `json:"locale"`
SepaConfig *SepaConfig `json:"sepa_config"`
}
type SepaConfig struct {
IBAN string `json:"iban" validate:"required"`
HolderName string `json:"holder_name" validate:"required"`
BIC string `json:"bic" validate:"required"`
CurrencyIdentifier string `json:"currency_identifier" validate:"required"`
}

View File

@@ -1,19 +1,24 @@
package models
type Config struct {
LogLevel string `mapstructure:"LOGLEVEL"`
Prod bool `mapstructure:"PRODUCION"`
Port string `mapstructure:"PORT"`
APIKey string `mapstructure:"APIKEY"`
EventName string `mapstructure:"EVENTNAME"`
CurrencySymbol string `mapstructure:"CURRENCYSYMBOL"`
CurrencyIdentifier string `mapstructure:"CURRENCYIDENTIFIER"`
CardSubtitle string `mapstructure:"CARD_SUBTITLE"`
CardBarcodeFormat string `mapstructure:"CARD_BARCODEFORMAT"`
CardBarcodePrefix string `mapstructure:"CARD_BARCODEPREFIX"`
SponsoringReceiptMinimum int `mapstructure:"SPONSOING_RECEIPTMINIMUM"`
SponsoringReceiptMinimum string `mapstructure:"SPONSORING_RECEIPTMINIMUM"`
SponosringDisclaimer string `mapstructure:"SPONSORING_DISCLAIMER"`
SponsoringBarcodeFormat string `mapstructure:"SPONSORING_BARCODEFORMAT"`
SponsoringBarcodePrefix string `mapstructure:"SPONSORING_BARCODEPREFIX"`
CertificateFooter string `mapstructure:"CERTIFICATE_FOOTER"`
GotenbergBaseUrl string `mapstructure:"GOTENBERG_BASEURL"`
RedisAddr string `mapstructure:"REDIS_ADDR"`
SepaBic string `mapstructure:"SEPA_BIC"`
SepaName string `mapstructure:"SEPA_NAME"`
SepaIban string `mapstructure:"SEPA_IBAN"`
}

View File

@@ -14,14 +14,17 @@ type Runner struct {
}
type Group struct {
Name string `json:"name" validate:"required"`
Name string `json:"name" validate:"required"`
ParentGroup struct {
Name string `json:"name" validate:"required"`
} `json:"parent_group" validate:"optional"`
}
type ContractTemplateOptions struct {
Runners []Runner `json:"runners"`
CurrencySymbol string `json:"currency_symbol"`
Disclaimer string `json:"disclaimer"`
ReceiptMinimumAmount int `json:"receipt_minimum_amount"`
ReceiptMinimumAmount string `json:"receipt_minimum_amount"`
EventName string `json:"event_name"`
BarcodeFormat string `json:"barcode_format"`
BarcodePrefix string `json:"barcode_prefix"`

View File

@@ -8,7 +8,6 @@ import (
"image/color"
"image/draw"
"image/png"
"log"
"slices"
"time"
@@ -17,6 +16,7 @@ import (
"github.com/boombuler/barcode/ean"
"github.com/boombuler/barcode/qr"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
type BarcodeService interface {
@@ -26,19 +26,26 @@ type BarcodeService interface {
type DefaultBarcodeService struct {
RedisClient *redis.Client
Logger *zap.SugaredLogger
}
func (b *DefaultBarcodeService) GenerateBarcode(format string, content string, width int, height int, padding int) (bytes.Buffer, error) {
ctx := context.Background()
logger := b.Logger.Named("GenerateBarcode")
if !b.IsTypeSupported(format) {
logger.Errorw("Unsupported barcode type", "type", format)
return bytes.Buffer{}, fmt.Errorf("unsupported barcode type: %s", format)
}
logger = logger.With("type", format, "content", content, "width", width, "height", height, "padding", padding)
cacheKey := fmt.Sprintf("barcode:%s:%s:%d:%d:%d", format, content, width, height, padding)
if b.RedisClient != nil {
cachedBarcode, err := b.RedisClient.Get(ctx, fmt.Sprintf("barcode:%s:%s:%d:%d:%d", format, content, width, height, padding)).Result()
logger.Debugw("Checking cache for barcode", "key", cacheKey)
cachedBarcode, err := b.RedisClient.Get(ctx, cacheKey).Result()
if err == nil {
log.Printf("Cache hit for barcode:%s:%s:%d:%d", format, content, width, height)
logger.Infow("Barcode found in cache", "key", cacheKey)
buf := bytes.Buffer{}
buf.Write([]byte(cachedBarcode))
return buf, nil
@@ -62,7 +69,11 @@ func (b *DefaultBarcodeService) GenerateBarcode(format string, content string, w
}
break
case "qr":
generatedCode, err = qr.Encode(content, qr.M, qr.AlphaNumeric)
// Always use qr.Auto encoding to support all characters in the content
encoding := qr.Auto
// QR code generation with error correction level M and auto encoding
generatedCode, err = qr.Encode(content, qr.M, encoding)
if err != nil {
return bytes.Buffer{}, err
}
@@ -73,6 +84,7 @@ func (b *DefaultBarcodeService) GenerateBarcode(format string, content string, w
bg := image.NewRGBA(image.Rect(0, 0, width, height))
white := color.RGBA{255, 255, 255, 255}
draw.Draw(bg, bg.Bounds(), &image.Uniform{white}, image.Point{}, draw.Src)
logger.Debug("Created white background")
// Calculate the new size for the barcode to fit within the padding
newWidth := width - 2*padding
@@ -81,24 +93,32 @@ func (b *DefaultBarcodeService) GenerateBarcode(format string, content string, w
// Scale the barcode to the new size
scaledCode, err := barcode.Scale(generatedCode, newWidth, newHeight)
if err != nil {
logger.Errorw("Failed to scale barcode", "error", err)
return bytes.Buffer{}, err
}
logger.Debug("Scaled barcode")
// Draw the barcode on top of the white background with padding
draw.Draw(bg, scaledCode.Bounds().Add(image.Point{padding, padding}), scaledCode, image.Point{}, draw.Over)
logger.Debug("Drew barcode on background")
var buf bytes.Buffer
err = png.Encode(&buf, bg)
if err != nil {
logger.Errorw("Failed to encode barcode to PNG", "error", err)
return bytes.Buffer{}, err
}
logger.Debug("Encoded barcode to PNG")
if b.RedisClient != nil {
err = b.RedisClient.Set(ctx, fmt.Sprintf("barcode:%s:%s:%d:%d", format, content, width, height), buf.String(), 10*time.Minute).Err()
err = b.RedisClient.Set(ctx, cacheKey, buf.String(), 10*time.Minute).Err()
logger.Debugw("Cached barcode", "key", cacheKey)
if err != nil {
logger.Errorw("Failed to cache barcode", "error", err)
return bytes.Buffer{}, err
}
}
logger.Info("Generated barcode")
return buf, nil
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"github.com/oxplot/papersizes"
"go.uber.org/zap"
)
type Converter interface {
@@ -16,92 +17,115 @@ type Converter interface {
type GotenbergConverter struct {
BaseUrl string
Logger *zap.SugaredLogger
}
func (g *GotenbergConverter) ToPdf(html string, pageSize string, landscape bool) ([]byte, error) {
logger := g.Logger.Named("ToPdf").With("page_size", pageSize, "landscape", landscape, "base_url", g.BaseUrl)
client := &http.Client{}
defer client.CloseIdleConnections()
logger.Debug("Created HTTP client")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", "index.html")
if err != nil {
logger.Errorw("Failed to create form file", "error", err)
return nil, err
}
_, err = part.Write([]byte(html))
if err != nil {
logger.Errorw("Failed to write to form file", "error", err)
return nil, err
}
size := papersizes.FromName(pageSize)
if size == nil {
logger.Errorw("Invalid page size", "size", pageSize)
return nil, fmt.Errorf("invalid page size: %s", pageSize)
}
err = writer.WriteField("paperWidth", strconv.Itoa(size.Width)+"mm")
if err != nil {
logger.Errorw("Failed to write paper width", "error", err)
return nil, err
}
err = writer.WriteField("paperHeight", strconv.Itoa(size.Height)+"mm")
if err != nil {
logger.Errorw("Failed to write paper height", "error", err)
return nil, err
}
err = writer.WriteField("landscape", strconv.FormatBool(landscape))
if err != nil {
logger.Errorw("Failed to write landscape", "error", err)
return nil, err
}
err = writer.WriteField("marginTop", "0")
if err != nil {
logger.Errorw("Failed to write margin top", "error", err)
return nil, err
}
err = writer.WriteField("marginBottom", "0")
if err != nil {
logger.Errorw("Failed to write margin bottom", "error", err)
return nil, err
}
err = writer.WriteField("marginLeft", "0")
if err != nil {
logger.Errorw("Failed to write margin left", "error", err)
return nil, err
}
err = writer.WriteField("marginRight", "0")
if err != nil {
logger.Errorw("Failed to write margin right", "error", err)
return nil, err
}
err = writer.WriteField("preferCssPageSize", "true")
if err != nil {
logger.Errorw("Failed to write prefer css page size", "error", err)
return nil, err
}
err = writer.Close()
if err != nil {
logger.Errorw("Failed to close writer", "error", err)
return nil, err
}
logger.Debug("Created form data")
logger.Debug("Creating HTTP request")
req, err := http.NewRequest("POST", g.BaseUrl+"/forms/chromium/convert/html", body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
logger.Debug("Sending HTTP request")
resp, err := client.Do(req)
if err != nil {
logger.Errorw("Failed to send request", "error", err)
return nil, err
}
logger.Debug("Received HTTP response")
defer resp.Body.Close()
data := new(bytes.Buffer)
_, err = data.ReadFrom(resp.Body)
if err != nil {
logger.Errorw("Failed to read response body", "error", err)
return nil, err
}
logger.Debug("Returning PDF data")
return data.Bytes(), nil
}

View File

@@ -7,6 +7,8 @@ import (
"fmt"
"html/template"
"strings"
"go.uber.org/zap"
)
type Templater interface {
@@ -17,6 +19,7 @@ type Templater interface {
type DefaultTemplater struct {
BarcodeService BarcodeService
StaticService StaticService
Logger *zap.SugaredLogger
}
func idToEan13(id string, prefix string) (string, error) {
@@ -32,6 +35,28 @@ func idToEan13(id string, prefix string) (string, error) {
return id, nil
}
func (t *DefaultTemplater) GenerateEPC(iban string, bic string, name string, title string, amount int, currency string) (string, error) {
var err error
code := fmt.Sprintf(`
BCD
002
1
SCT
%s
%s
%s
%s%.2f
%s
`, bic, name, iban, currency, float64(amount)/100, title,
)
buf, err := t.BarcodeService.GenerateBarcode("qr", code, 500, 500, 0)
return base64.StdEncoding.EncodeToString(buf.Bytes()), err
}
func (t *DefaultTemplater) GenerateBarcode(code string, format string, prefix string) (string, error) {
var err error
@@ -48,10 +73,13 @@ func (t *DefaultTemplater) GenerateBarcode(code string, format string, prefix st
}
func (t *DefaultTemplater) SelectSponsorImage(id int) (string, error) {
logger := t.Logger.Named("SelectSponsorImage")
sponsors, err := t.StaticService.ListFilesInStaticSubFolder("images/sponsors")
if err != nil {
logger.Errorw("Failed to list sponsors", "error", err)
return "", err
}
logger.Debugw("Selected sponsor", "sponsors", sponsors, "id", id, "selected", sponsors[id%len(sponsors)])
return t.StaticService.GetImage("sponsors/" + strings.TrimSuffix(sponsors[id%len(sponsors)], ".base64")), nil
}
@@ -82,6 +110,7 @@ func (t *DefaultTemplater) StringToTemplate(templateString string) (*template.Te
"sponsorLogo": t.SelectSponsorImage,
"formatUnit": t.FormatUnit,
"loadImage": t.LoadImage,
"epcCode": t.GenerateEPC,
}).Parse(templateString)
}

View File

@@ -3,8 +3,9 @@ package services
import (
_ "embed"
"fmt"
"log"
"os"
"go.uber.org/zap"
)
type StaticService interface {
@@ -14,47 +15,55 @@ type StaticService interface {
}
type DefaultStaticService struct {
Cache map[string]string
Cache map[string]string
Logger *zap.SugaredLogger
}
func (s *DefaultStaticService) GetTemplate(locale, templateName string) (string, error) {
logger := s.Logger.Named("GetTemplate").With("locale", locale, "template_name", templateName)
if s.Cache[locale+templateName] != "" {
log.Printf("returning cached template %s with locale %s", templateName, locale)
logger.Debugw("Template found in cache", "key", locale+templateName)
return s.Cache[locale+templateName], nil
}
content, err := os.ReadFile(fmt.Sprintf("static/templates/%s/%s.html", templateName, locale))
if content == nil || err != nil {
log.Printf("error reading template %s with locale %s: %v", templateName, locale, err)
logger.Errorw("Failed to read template", "error", err)
return "", err
}
s.Cache[locale+templateName] = string(content)
logger.Debugw("Saved template to cache", "key", locale+templateName)
return string(content), nil
}
func (s *DefaultStaticService) ListFilesInStaticSubFolder(folderName string) ([]string, error) {
logger := s.Logger.Named("ListFilesInStaticSubFolder").With("folder_name", folderName)
files, err := os.ReadDir(fmt.Sprintf("static/%s", folderName))
if err != nil {
log.Printf("error reading files from folder %s: %v", folderName, err)
logger.Errorw("Failed to list files", "error", err)
return nil, err
}
var images []string
for _, file := range files {
if file.IsDir() {
continue
logger.Debugw("Skipping directory", "file", file.Name())
}
images = append(images, file.Name())
}
logger.Debugw("Listed files", "files", images)
return images, nil
}
func (s *DefaultStaticService) GetImage(imageName string) string {
logger := s.Logger.Named("GetImage").With("image_name", imageName)
content, err := os.ReadFile("static/images/" + imageName + ".base64")
if content == nil || err != nil {
log.Printf("error reading image %s: %v", imageName, err)
logger.Errorw("Failed to read image", "error", err)
return ImageErrorBase64
}
return string(content)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="utf8">
<title>Sponsoring contract</title>
<title>Läuferkarten</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<style>
.sheet {
@@ -50,8 +50,12 @@
<p style="font-size: 0.6rem; text-align: center; margin: 0; padding: 0;">{{ .Code }}</p>
</div>
</div>
{{ if ne .Runner.FirstName "" }}
<p>{{ .Runner.LastName }}, {{ .Runner.FirstName }} {{ .Runner.MiddleName }}</p>
<p>{{ .Runner.Group.Name }}</p>
<p>{{ if ne .Runner.Group.ParentGroup.Name "" -}}{{ .Runner.Group.ParentGroup.Name }}/{{end -}}{{ .Runner.Group.Name }}</p>
{{ else }}
<p>Kein Läufer zugewiesen</p>
{{ end}}
{{ end}}
</div>
{{ end }}

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="utf8">
<title>Sponsoring contract</title>
<title>Runner cards</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<style>
.sheet {
@@ -49,8 +49,12 @@
<p style="font-size: 0.6rem; text-align: center; margin: 0; padding: 0;">{{ .Code }}</p>
</div>
</div>
{{ if ne .Runner.FirstName ""}}
<p>{{ .Runner.LastName }}, {{ .Runner.FirstName }} {{ .Runner.MiddleName }}</p>
<p>{{ .Runner.Group.Name }}</p>
<p>{{ if ne .Runner.Group.ParentGroup.Name "" -}}{{ .Runner.Group.ParentGroup.Name }}/{{end -}}{{ .Runner.Group.Name }}</p>
{{ else }}
<p>Blank card</p>
{{ end}}
</div>
{{ end }}
</div>

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="utf8">
<title>Sponsoring contract</title>
<title>Läuferurkunde</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<style>
.sheet {
@@ -87,13 +87,30 @@
<td>Gesamt</td>
<td>{{ formatUnit "euro" $.Locale .TotalPerDistance }} {{ $.CurrencySymbol }}</td>
<td>{{ formatUnit "euro" $.Locale .TotalDonations }} {{ $.CurrencySymbol }}</td>
</tfoot>
</table>
</main>
<footer class="certificate-footer">
<p>
{{ $.Footer }}
</p>
</table>
</main>
<footer class="certificate-footer">
<table style="border-collapse: collapse; border: none; width: 17cm;">
<thead>
<tr>
<th style="border: none; width: 50%; text-align: center;">Link zu deinen Rundenzeiten</th>
<th style="border: none; width: 50%; text-align: center;">Spende überweisen</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: none; text-align: center;">
<img src="data:image/png;base64,{{ barcode .SelfServiceLink "qr" "" }}" style="height: 2.5cm; padding: 0.2cm">
</td>
<td style="border: none; text-align: center;">
<img src="data:image/png;base64,{{ epcCode $.SepaConfig.IBAN $.SepaConfig.BIC $.SepaConfig.HolderName (print "Sponsoring für " .FirstName " " .LastName ", " .CombinedGroupName) .TotalDonations $.SepaConfig.CurrencyIdentifier}}" style="height: 2.5cm; padding: 0.2cm">
</td>
</tr>
</tbody>
</table>
<p style="width: 17cm; text-align: center;">
{{ $.Footer }}
</p>
</footer>
</article>
{{ end }}

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="utf8">
<title>Sponsoring contract</title>
<title>Runner certificate</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<style>
.sheet {
@@ -91,7 +91,25 @@
</table>
</main>
<footer class="certificate-footer">
<p>
<table style="border-collapse: collapse; border: none; width: 17cm;">
<thead>
<tr>
<th style="border: none; width: 50%; text-align: center;">Link to your lap times</th>
<th style="border: none; width: 50%; text-align: center;">Transfer donation via SEPA</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: none; text-align: center;">
<img src="data:image/png;base64,{{ barcode .SelfServiceLink "qr" "" }}" style="height: 2.5cm; padding: 0.2cm">
</td>
<td style="border: none; text-align: center;">
<img src="data:image/png;base64,{{ epcCode $.SepaConfig.IBAN $.SepaConfig.BIC $.SepaConfig.HolderName (print "Sponsoring for " .FirstName " " .LastName ", " .CombinedGroupName) .TotalDonations $.SepaConfig.CurrencyIdentifier}}" style="height: 2.5cm; padding: 0.2cm">
</td>
</tr>
</tbody>
</table>
<p style="width: 17cm; text-align: center;">
{{ $.Footer }}
</p>
</footer>

View File

@@ -65,8 +65,8 @@
<p style="font-size: x-small; display: block;">Nachname</p>
</div>
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{ .Group.Name }}</span>
<p style="font-size: x-small; display: block;">Team/ Klasse</p>
<span style="border-bottom: 1px solid; width: 100%; display: block;"><p>{{ if ne .Group.ParentGroup.Name "" -}}{{ .Group.ParentGroup.Name }}/ {{end -}}{{ .Group.Name }}</p></span>
<p style="font-size: x-small; display: block;">Team/Klasse</p>
</div>
</div>
<p style="margin-top: -0.5rem">mit einem Betrag von _____ {{ $.CurrencySymbol }} pro gelaufenem Kilometer zu

View File

@@ -64,7 +64,7 @@
<p style="font-size: x-small; display: block;">Last Name</p>
</div>
<div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{ .Group.Name}}</span>
<span style="border-bottom: 1px solid; width: 100%; display: block;"><p>{{ if ne .Group.ParentGroup.Name "" -}}{{ .Group.ParentGroup.Name }}/ {{end -}}{{ .Group.Name }}</p></span>
<p style="font-size: x-small; display: block;">Team/class</p>
</div>
</div>