diff --git a/.air.toml b/.air.toml index 9626a59..653d81c 100644 --- a/.air.toml +++ b/.air.toml @@ -1,11 +1,11 @@ root = "." testdata_dir = "testdata" -tmp_dir = "tmp" +tmp_dir = "build" [build] args_bin = [] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main ./internal/cmd/server/" + bin = "make run" + cmd = "make build" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] diff --git a/.gitignore b/.gitignore index 856dfd9..a919a04 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ tmp/ .env config.yaml config.yml - +build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb40fec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app + +RUN apk add --no-cache make + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN make build-static \ + && cp build/clavis /clavis \ + && chmod +x /clavis + +FROM alpine:latest AS production +WORKDIR /app + +COPY --from=builder /clavis /app/clavis + +RUN apk update --no-cache && apk add --no-cache ca-certificates + +ENV LISTEN_ADDRESS=0.0.0.0 \ + LISTEN_PORT=3000 \ + POSTGRES_USER=root \ + POSTGRES_PASSWORD=Ch@nG!E.ME!!\ + POSTGRES_ADDRESS=postgresql \ + POSTGRES_PORT=5432 \ + POSTGRES_DATABASE="clavis" \ + VERIFY_DEFAULT=true + +CMD ["/app/clavis"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..17ca20b --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +build: + go build -o build/clavis ./internal/cmd/server/ + +build-static: + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o build/clavis ./internal/cmd/server + +run: build + ./build/clavis + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..032a298 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# HKP-Clavis + +HKP-Clavis was born out of a desire for simplicity. I found existing solutions like hkp-hagrid to be over-engineered and difficult to administer for smaller, focused deployments. My goal was to create a "minimally stable" implementation of an HKP server that does exactly what is needed—no more, no less—while maintaining high performance. + + - Note on Storage: During testing, it became clear that handling large keys (>2MB) directly in PostgreSQL isn't ideal. In the future, I plan to add S3/FileStorage support for blobs to keep the database lean and mean + + +## 🚀 Features + +* **HKP Protocol Support**: Implements core `add`, `get`, and `index` operations. +* **High-Performance Architecture**: + * **Object Pooling**: Uses `sync.Pool` via a custom `KeyManager` to reuse `PGPKey` and `PGPUid` objects, significantly reducing GC pressure. + * **Efficient Memory Allocation**: Controller and Service layers are built to minimize allocations during key parsing and transformation. +* **Database Optimization**: + * **PostgreSQL Backend**: Utilizes `pgx/v5` for robust connection pooling. +* **Security & Sanitization**: + * **Verification-Aware**: Only returns UIDs that have been marked as verified in the database. + * **Key Sanitization**: Automatically filters out sensitive or unverified metadata before serving keys. + +## 🛠 Tech Stack + +* **Language**: Go 1.22+ +* **Database**: PostgreSQL +* **Library**: `ProtonMail/go-crypto` (for OpenPGP operations) +* **Driver**: `pgx/v5` + +## 📋 Backlog & Future Roadmap + +The project is currently in a "feature freeze" for the core engine. The following features are planned for future development: + +### 1. 🌐 GOTH Stack Web Interface +* **Frontend**: Implement a lightweight web UI using **Go Templates + HTMX** (The GOTH Stack). +* **Search**: A user-friendly search bar for finding keys without using the CLI. +* **Stats**: A dashboard showing server uptime, key count, and pool utilization metrics. +* **Kick GPG**: Debug. why key.openpgp.org server (hagrid) and clavis not work with `gpg --recv-key` T__T + +### 2. 🗄️ Hybrid Storage & Large Payload Handling +* **Blob Storage Integration**: Implement an external Blob Storage (e.g., MinIO, S3, or local disk) for keys exceeding **1MB** in size to keep the PostgreSQL database lean. +* **Metadata/Binary Split**: Store key metadata in Postgres and the actual `.asc` packets in Blob storage. + +### 3. 📧 Email Verification System +* **SMTP Service**: Activate the `SMTPPool` for sending verification emails. +* **Template Engine**: Support for both HTML and Plaintext email templates (required for classic PGP users). +* **Verification Route**: Implement the `GET /verify` endpoint to process tokens and update UID status. + +### 4. 🛡️ System Hardening +* **Rate Limiting**: Add middleware to prevent brute-force key uploads or search "scraping". +* **SKS Peering**: Support for the synchronization protocol to federate with other global keyservers. + +## ⚙️ Configuration + +The application is configured via environment variables: + +| Variable | Description | +| :--- | :--- | +| `LISTEN_ADDRESS` | Host to bind the server (e.g., `localhost`) | +| `LISTEN_PORT` | Port for the HKP service (e.g., `8181`) | +| `POSTGRES_USER` | Database user | +| `POSTGRES_PASSWORD` | Database password | +| `POSTGRES_ADDRESS` | Database host | +| `POSTGRES_PORT` | Database port | +| `POSTGRES_DATABASE` | Database name | +| `VERIFY_DEFAULT` | If `true`, new keys are marked as verified immediately (Dev mode) | + +ps: verify by smtp not implemented `VERIFY_DEFAULT=true` is recommended + +## 📦 Installation & Usage + +```bash +# Build the binary +go build -o build/hkp-clavis internal/cmd/server/ + +# Run the server (ensure env vars are set) +./build/hkp-clavis diff --git a/docker-compose.yaml b/docker-compose.yaml index 2db8d04..6ac8e72 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,41 @@ services: - postgres_test: - image: postgres:17-alpine + postgres: + image: postgres:17-alpine + container_name: clavis_db environment: - POSTGRES_DB: test_hagrid_db - POSTGRES_USER: test_user - POSTGRES_PASSWORD: test_password + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + # ports: + # - "${POSTGRES_PORT}:${POSTGRES_PORT}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + + clavis: + build: + context: . + dockerfile: Dockerfile + container_name: clavis_app + depends_on: + postgres: + condition: service_healthy + environment: + - LISTEN_ADDRESS=${LISTEN_ADDRESS} + - LISTEN_PORT=${LISTEN_PORT} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_ADDRESS=${POSTGRES_HOST} + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DATABASE=${POSTGRES_DB} + - VERIFY_DEFAULT=${VERIFY_DEFAULT} ports: - - "5433:5432" + - "${EXTERNAL_PORT}:${LISTEN_PORT}" + restart: always + +volumes: + postgres_data: diff --git a/go.mod b/go.mod index b4c1faf..3c76f87 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gadrid +module hkp-clavis go 1.24.4 @@ -6,6 +6,8 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/emersion/go-openpgp-hkp v0.0.0-20231106091114-bb7c1dad7252 github.com/jackc/pgx/v5 v5.7.5 + github.com/joho/godotenv v1.5.1 + github.com/lmittmann/tint v1.1.3 github.com/stretchr/testify v1.8.1 ) diff --git a/go.sum b/go.sum index ca469a8..c78bd4a 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,14 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= +github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/internal/cmd/server/main.go b/internal/cmd/server/main.go index 6639768..ba6233d 100644 --- a/internal/cmd/server/main.go +++ b/internal/cmd/server/main.go @@ -3,43 +3,105 @@ package main import ( "context" "fmt" + "log" + "log/slog" "net/http" + "os" + "strconv" - "gadrid/internal/repo/storage/postgresql" - storageService "gadrid/internal/service/storage/serv" + controllerHkp "hkp-clavis/internal/controller/hkp" + "hkp-clavis/internal/model" + "hkp-clavis/internal/repo/storage/postgresql" + storageService "hkp-clavis/internal/service/storage/serv" hkp "github.com/emersion/go-openpgp-hkp" "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + "github.com/lmittmann/tint" ) -const ( - listenAddress = "localhost:8181" -) +var Logger *slog.Logger func main() { - dbpool, err := pgxpool.New(context.TODO(), "postgresql://test_user:test_password@localhost:5433/test_hagrid_db") + Logger = slog.New(tint.NewHandler(os.Stdout, nil)) + + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, reading from system environment") + } + + listenAddress := os.Getenv("LISTEN_ADDRESS") + listenPort := os.Getenv("LISTEN_PORT") + + listenAddress = fmt.Sprintf("%s:%s", listenAddress, listenPort) + + pgUser := os.Getenv("POSTGRES_USER") + pgPassword := os.Getenv("POSTGRES_PASSWORD") + pgAddress := os.Getenv("POSTGRES_ADDRESS") + pgPort := os.Getenv("POSTGRES_PORT") + pgDatabase := os.Getenv("POSTGRES_DATABASE") + + // By default all uids in key does not verify + // For dev/debug you can set it to true + verify := os.Getenv("VERIFY_DEFAULT") + defaultVerify := getEnvAsBool(verify, true) + + pgUri := fmt.Sprintf( + "postgresql://%s:%s@%s:%s/%s", + pgUser, + pgPassword, + pgAddress, + pgPort, + pgDatabase) + + // For memory allocation controller + keyMngr := model.NewKeyManager() + + dbpool, err := pgxpool.New(context.TODO(), pgUri) if err != nil { panic(err) } - dbRepo := postgresql.New(context.TODO(), dbpool) - storageService := storageService.New(dbRepo) + db := postgresql.New(context.TODO(), dbpool, keyMngr) + storage := storageService.New(db, keyMngr, defaultVerify) - handler := hkp.Handler{ - Adder: storageService, - Lookuper: storageService, + controller, err := controllerHkp.New(storage) + if err != nil { + panic(err) } - http.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(w, r) + mux := http.NewServeMux() + hkpHandler := hkp.Handler{ + Adder: controller, + Lookuper: controller, + } + mux.HandleFunc("/pks/lookup", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pgp-keys") + w.Header().Set("Connection", "close") + w.Header().Set("Cache-Control", "no-cache") + + hkpHandler.ServeHTTP(w, r) }) - http.HandleFunc("/pks/add", func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(w, r) + mux.HandleFunc("/pks/add", func(w http.ResponseWriter, r *http.Request) { + hkpHandler.ServeHTTP(w, r) }) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Index Page") }) - fmt.Println("Server is listening on address:", listenAddress) - http.ListenAndServe(listenAddress, nil) + + handler := LogMiddleware(mux) + + Logger.Info("Server is listening on address: %s", handler) + http.ListenAndServe(listenAddress, handler) +} + +func getEnvAsBool(name string, defaultVal bool) bool { + valStr, exists := os.LookupEnv(name) // Use LookupEnv for better checking + if !exists || valStr == "" { + return defaultVal + } + if val, err := strconv.ParseBool(valStr); err == nil { + return val + } + return defaultVal } diff --git a/internal/cmd/server/middlewareLogger.go b/internal/cmd/server/middlewareLogger.go new file mode 100644 index 0000000..01a7445 --- /dev/null +++ b/internal/cmd/server/middlewareLogger.go @@ -0,0 +1,69 @@ +package main + +import ( + "log/slog" + "net/http" + "strings" + "sync" + "time" +) + +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} +func (rw *responseWriter) Write(b []byte) (int, error) { + if rw.statusCode == 0 { + rw.statusCode = http.StatusOK + } + return rw.ResponseWriter.Write(b) +} + +var rwPool = sync.Pool{ + New: func() any { + return &responseWriter{} + }, +} + +// LogMiddleware +func LogMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + rw := rwPool.Get().(*responseWriter) + defer func() { + rw.ResponseWriter = nil + rwPool.Put(rw) + }() + + rw.ResponseWriter = w + // rw.statusCode = http.StatusOK + + next.ServeHTTP(rw, r) + + Logger.Info("request handled", + slog.String("ip", getIP(r)), + slog.String("method", r.Method), + slog.Int("status", rw.statusCode), + slog.String("path", r.URL.Path), + slog.Duration("duration", time.Since(start)), + ) + }) +} +func getIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + if i := strings.Index(forwarded, ","); i != -1 { + return forwarded[:i] + } + return forwarded + } + return r.RemoteAddr +} diff --git a/internal/controller/hkp/controller.go b/internal/controller/hkp/controller.go index d37c37e..6c9932b 100644 --- a/internal/controller/hkp/controller.go +++ b/internal/controller/hkp/controller.go @@ -1,7 +1,11 @@ package hkp import ( - "gadrid/internal/service/storage" + "context" + "fmt" + "hkp-clavis/internal/service/storage" + "strings" + "time" "github.com/ProtonMail/go-crypto/openpgp" hkp "github.com/emersion/go-openpgp-hkp" @@ -10,24 +14,69 @@ import ( type HkpController struct { hkp.Adder hkp.Lookuper - storage storage.StorageInterface - // verify + storageService storage.StorageInterface + // verifyService } +const ( + defaultTimeout = 30 * time.Second + minEntropy = 15 +) + func New(st storage.StorageInterface) (HkpController, error) { return HkpController{ - storage: st, + storageService: st, }, nil } -func (s HkpController) Add(k openpgp.EntityList) error { +func (s HkpController) Add(el openpgp.EntityList) error { + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*120) + defer cancel() + + k, err := s.storageService.Add(ctx, el) + if err != nil { + return fmt.Errorf("hkp controller error: Add: %w", err) + } + + _ = k + // Send Verify mail + // PS: Now verify false and you cant get key from DB + return nil } -func (s HkpController) Get(r *hkp.LookupRequest) (openpgp.EntityList, error) { - return openpgp.EntityList{}, nil +func (s HkpController) Get(req *hkp.LookupRequest) (openpgp.EntityList, error) { + + req.Search = strings.Trim(req.Search, "0x") + + if len(req.Search) <= 15 { + return nil, fmt.Errorf("controller error: Get: Low entropy string for search") + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + k, err := s.storageService.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("controller error: Get: %w", err) + } + + return k, err } -func (s HkpController) Index(r *hkp.LookupRequest) ([]hkp.IndexKey, error) { - return []hkp.IndexKey{}, nil +func (s HkpController) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) { + + if len(req.Search) < 5 { + return nil, fmt.Errorf("controller error: Index: short string for search") + } + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + k, err := s.storageService.Index(ctx, req) + if err != nil { + return nil, fmt.Errorf("controller error: Get: %w", err) + } + + return k, nil } diff --git a/internal/model/converter.go b/internal/model/converter.go new file mode 100644 index 0000000..a034069 --- /dev/null +++ b/internal/model/converter.go @@ -0,0 +1,143 @@ +package model + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "regexp" + "strings" + "sync" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +func extractEmail(fullNameWithEmail string) string { + re := regexp.MustCompile(`<([^>]+)>`) + matches := re.FindStringSubmatch(fullNameWithEmail) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +var ( + bufferPool = sync.Pool{ + New: func() any { return new(bytes.Buffer) }, + } +) + +// ConverterPgpToService converts a raw openpgp.EntityList into a slice of PGPkey objects. +// It parses key data, generates verification tokens for UIDs, and extracts email addresses. +// +// Parameters: +// +// entities: The openpgp.EntityList obtained from parsing an armored PGP block. +// +// Returns: +// +// []*PGPkey: A slice of converted PGPkey objects. Returns an empty (non-nil) slice if no valid entities are found. +// error: An error if any entity fails to be processed or contains invalid data. +func (m *KeyManager) ConverterPgpToService(entities openpgp.EntityList, defaultVerifyValue bool) ([]*PGPKey, func(), error) { + res := make([]*PGPKey, 0, len(entities)) + release := func() { + m.ReleaseSlice(res) + } + + for _, entity := range entities { + if entity == nil || entity.PrimaryKey == nil { + continue + } + + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + + publicKeyWriter, err := armor.Encode(buf, openpgp.PublicKeyType, nil) + if err != nil { + bufferPool.Put(buf) + return nil, release, fmt.Errorf("armor encode error: %w", err) + } + + if err := entity.Serialize(publicKeyWriter); err != nil { + publicKeyWriter.Close() + bufferPool.Put(buf) + return nil, release, fmt.Errorf("serialize error: %w", err) + } + publicKeyWriter.Close() + + pgpKey := pgpKeyPool.Get().(*PGPKey) + pgpKey.Reset() + + pgpKey.Fingerprint = strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)) + pgpKey.Packet = buf.String() + pgpKey.UpdateTime = entity.PrimaryKey.CreationTime.UTC() + pgpKey.Revoked = entity.Revoked(time.Now()) + + bufferPool.Put(buf) + + for _, identity := range entity.Identities { + if identity == nil || identity.UserId == nil || identity.Name == "" { + continue + } + + extractedEmail := extractEmail(identity.Name) + if extractedEmail == "" { + log.Printf("Warning: no email in UID %s for key %s", identity.Name, pgpKey.Fingerprint) + continue + } + + var tokenRaw [16]byte + if _, err := rand.Read(tokenRaw[:]); err != nil { + return nil, release, fmt.Errorf("token gen error: %w", err) + } + token := hex.EncodeToString(tokenRaw[:]) + + pgpKey.Uids = append(pgpKey.Uids, &PGPUid{ + Verify: defaultVerifyValue, + UIDString: identity.Name, + Email: extractedEmail, + Token: token, + TokenExpires: time.Now().Add(time.Hour * 5), + Fingerprint: pgpKey.Fingerprint, + }) + } + + res = append(res, pgpKey) + } + + return res, release, nil +} + +func SanitizeAndFilterKey(dbKey *PGPKey) (openpgp.EntityList, error) { + + parsedEntities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(dbKey.Packet)) + if err != nil { + return nil, fmt.Errorf("failed to parse key packet for %s: %w", dbKey.Fingerprint, err) + } + + if len(parsedEntities) == 0 { + return nil, fmt.Errorf("parsed key packet for %s yielded no entities", dbKey.Fingerprint) + } + var respEntities openpgp.EntityList + for _, e := range parsedEntities { + if e == nil { + return nil, fmt.Errorf("no entity with matching fingerprint %s found in parsed packet from DB", dbKey.Fingerprint) + } + verifiedUIDStringsFromDB := make(map[string]struct{}) + for _, dbUID := range dbKey.Uids { + verifiedUIDStringsFromDB[dbUID.UIDString] = struct{}{} + } + filteredIdentities := make(map[string]*openpgp.Identity) + for identityKey, identity := range e.Identities { + if _, isVerified := verifiedUIDStringsFromDB[identity.Name]; isVerified { + filteredIdentities[identityKey] = identity // Keep this identity + } + } + e.Identities = filteredIdentities + respEntities = append(respEntities, e) + } + return respEntities, nil +} diff --git a/internal/model/manager.go b/internal/model/manager.go new file mode 100644 index 0000000..2a908e7 --- /dev/null +++ b/internal/model/manager.go @@ -0,0 +1,59 @@ +package model + +import "sync" + +var pgpKeyPool = sync.Pool{ + New: func() any { + return &PGPKey{ + Uids: make([]*PGPUid, 0, 10), + } + }, +} +var pgpUidPool = sync.Pool{ + New: func() any { + return &PGPUid{} + }, +} + +// Struct for managed keys allocation in memory +type KeyManager struct{} + +func NewKeyManager() *KeyManager { + return &KeyManager{} +} + +func (m *KeyManager) Get() *PGPKey { + k := pgpKeyPool.Get().(*PGPKey) + k.Reset() + return k +} +func (m *KeyManager) GetUid() *PGPUid { + u := pgpUidPool.Get().(*PGPUid) + u.Reset() + return u +} + +func (m *KeyManager) ReleaseUid(u ...*PGPUid) { + for _, u := range u { + if u == nil { + continue + } + pgpUidPool.Put(u) + } +} + +func (m *KeyManager) Release(keys ...*PGPKey) { + for _, k := range keys { + if k == nil { + continue + } + m.ReleaseUid(k.Uids...) + // Unlink all uids objects + k.Uids = k.Uids[:0] + pgpKeyPool.Put(k) + } +} + +func (m *KeyManager) ReleaseSlice(keys []*PGPKey) { + m.Release(keys...) +} diff --git a/internal/model/pgp_model.go b/internal/model/pgp_model.go new file mode 100644 index 0000000..db3df37 --- /dev/null +++ b/internal/model/pgp_model.go @@ -0,0 +1,37 @@ +package model + +import "time" + +type PGPKey struct { + Revoked bool + UpdateTime time.Time + Fingerprint string + Packet string + Uids []*PGPUid +} + +// Clear data from object +func (k *PGPKey) Reset() { + k.Fingerprint = "" + k.Packet = "" + k.Revoked = false + k.Uids = k.Uids[:0] +} + +type PGPUid struct { + Verify bool + Token string + TokenExpires time.Time + Email string + UIDString string // "Full Name (comment) " + Fingerprint string +} + +func (u *PGPUid) Reset() { + u.Verify = false + u.Token = "" + u.TokenExpires = time.Time{} + u.Email = "" + u.UIDString = "" + u.Fingerprint = "" +} diff --git a/internal/repo/mail/interface.go b/internal/repo/mail/interface.go new file mode 100644 index 0000000..ed75627 --- /dev/null +++ b/internal/repo/mail/interface.go @@ -0,0 +1,5 @@ +package mail + +type MailSenderRepo interface { + SendVerifyMessage() +} diff --git a/internal/repo/mail/smtp/smtp.go b/internal/repo/mail/smtp/smtp.go new file mode 100644 index 0000000..80cdbe0 --- /dev/null +++ b/internal/repo/mail/smtp/smtp.go @@ -0,0 +1 @@ +package smtp diff --git a/internal/repo/storage/interface.go b/internal/repo/storage/interface.go index ea37816..f7ba1a0 100644 --- a/internal/repo/storage/interface.go +++ b/internal/repo/storage/interface.go @@ -3,7 +3,7 @@ package storage import ( "context" "fmt" - service "gadrid/internal/service/storage" + "hkp-clavis/internal/model" ) var ( @@ -17,11 +17,11 @@ type StorageRepositotyInterface interface { // // Parameters: // ctx: Context for the operation. - // keys: A slice of service.PGPkey objects to be added or updated. + // keys: A slice of model.PGPKey objects to be added or updated. // // Returns: // error: An error if the operation fails, wrapped with context (e.g., database connection issues, SQL execution errors). - AddKey(ctx context.Context, keys []*service.PGPkey) error + AddKey(ctx context.Context, keys []*model.PGPKey) error // VerifyUID updates the verification status of a specific User ID (UID) based on a token. // It checks the token's validity and expiry before setting 'verified' to true and clearing token fields. @@ -50,11 +50,11 @@ type StorageRepositotyInterface interface { // fingerprint: The fingerprint of the PGP key to retrieve. // // Returns: - // []*service.PGPkey: A slice containing the retrieved PGP key (or an empty slice if not found). + // []*model.PGPKey: A slice containing the retrieved PGP key (or an empty slice if not found). // error: Returns an error if: // - The key is not found (errors.Is(err, ErrKeyNotFound)). // - Database query or scanning fails. - GetKey(ctx context.Context, fingerprint string) ([]*service.PGPkey, error) + GetKey(ctx context.Context, fingerprint string) ([]*model.PGPKey, error) // Index searches for PGP keys based on a query string. // It matches against UID strings and emails using case-insensitive regular expressions. @@ -65,9 +65,9 @@ type StorageRepositotyInterface interface { // q: The query string (treated as a case-insensitive regular expression). // // Returns: - // []*service.PGPkey: A slice of matching PGPKey objects, each containing only their verified UIDs. + // []*model.PGPKey: A slice of matching PGPKey objects, each containing only their verified UIDs. // error: Returns an error if database query or scanning fails. - Index(ctx context.Context, q string) ([]*service.PGPkey, error) + Index(ctx context.Context, q string) ([]*model.PGPKey, error) // CleanupStaleKeys identifies and deletes PGP keys that no longer have any associated UIDs. // This method is intended to be run periodically, e.g., by a cron job. // diff --git a/internal/repo/storage/mock.go b/internal/repo/storage/mock.go index cf35b49..e84b9b1 100644 --- a/internal/repo/storage/mock.go +++ b/internal/repo/storage/mock.go @@ -7,34 +7,35 @@ import ( "sync" "time" - service "gadrid/internal/service/storage" // Adjust this import path + "hkp-clavis/internal/model" + // Adjust this import path ) // InMemStorageMock is a mock implementation of PGPKeyStorage for testing purposes. // It stores data in memory, mimicking database operations. type InMemStorageMock struct { - keys map[string]*service.PGPkey - uids map[string]map[string]*service.PGPUid // fingerprint -> uid_string -> UID - mu sync.RWMutex // Mutex for concurrent access safety + keys map[string]*model.PGPKey + uids map[string]map[string]*model.PGPUid // fingerprint -> uid_string -> UID + mu sync.RWMutex // Mutex for concurrent access safety } // NewInMemStorageMock creates a new in-memory mock storage. func NewInMemStorageMock() *InMemStorageMock { return &InMemStorageMock{ - keys: make(map[string]*service.PGPkey), - uids: make(map[string]map[string]*service.PGPUid), + keys: make(map[string]*model.PGPKey), + uids: make(map[string]map[string]*model.PGPUid), } } // AddKey implements PGPKeyStorage interface for the mock. // See PGPKeyStorage interface for documentation. -func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*service.PGPkey) error { +func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*model.PGPKey) error { m.mu.Lock() defer m.mu.Unlock() for _, k := range keys { // Simulate UPSERT for pgp_keys - m.keys[k.Fingerprint] = &service.PGPkey{ // Deep copy + m.keys[k.Fingerprint] = &model.PGPKey{ // Deep copy Fingerprint: k.Fingerprint, Packet: k.Packet, Revoked: k.Revoked, @@ -43,14 +44,14 @@ func (m *InMemStorageMock) AddKey(ctx context.Context, keys []*service.PGPkey) e // Ensure the inner map exists for UIDs if _, ok := m.uids[k.Fingerprint]; !ok { - m.uids[k.Fingerprint] = make(map[string]*service.PGPUid) + m.uids[k.Fingerprint] = make(map[string]*model.PGPUid) } // Simulate INSERT (ON CONFLICT DO NOTHING) for pgp_uids for _, u := range k.Uids { if _, exists := m.uids[k.Fingerprint][u.UIDString]; !exists { - m.uids[k.Fingerprint][u.UIDString] = &service.PGPUid{ // Deep copy - UIDString: u.UIDString, + m.uids[k.Fingerprint][u.UIDString] = &model.PGPUid{ // Deep copy + UIDString: u.UIDString, Email: u.Email, Verify: u.Verify, Token: u.Token, @@ -73,7 +74,7 @@ func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, to return fmt.Errorf("invalid verification token or link") // Mimic no matching entry } - var targetUID *service.PGPUid + var targetUID *model.PGPUid var targetUIDString string // To be used as key in the map for update for uidStr, uid := range keyUids { if uid.Email == email { @@ -99,18 +100,18 @@ func (m *InMemStorageMock) VerifyUID(ctx context.Context, fingerprint, email, to // Simulate update targetUID.Verify = true - targetUID.Token = "" // Simulate setting to null + targetUID.Token = "" // Simulate setting to null targetUID.TokenExpires = time.Time{} // Simulate setting to null // Ensure the updated UID is correctly stored back in the map m.uids[fingerprint][targetUIDString] = targetUID - + return nil } // GetKey implements PGPKeyStorage interface for the mock. // See PGPKeyStorage interface for documentation. -func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*service.PGPkey, error) { +func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*model.PGPKey, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -120,11 +121,11 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s } // Create a deep copy of the key - resultKey := &service.PGPkey{ + resultKey := &model.PGPKey{ Fingerprint: pgpKey.Fingerprint, Packet: pgpKey.Packet, Revoked: pgpKey.Revoked, - Uids: []*service.PGPUid{}, // Only add verified UIDs + Uids: []*model.PGPUid{}, // Only add verified UIDs } // Filter UIDs by 'verified = true' @@ -132,8 +133,8 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s for _, uid := range keyUids { if uid.Verify { // Deep copy the UID - resultKey.Uids = append(resultKey.Uids, &service.PGPUid{ - UIDString: uid.UIDString, + resultKey.Uids = append(resultKey.Uids, &model.PGPUid{ + UIDString: uid.UIDString, Email: uid.Email, Verify: uid.Verify, Token: uid.Token, @@ -143,12 +144,12 @@ func (m *InMemStorageMock) GetKey(ctx context.Context, fingerprint string) ([]*s } } - return []*service.PGPkey{resultKey}, nil + return []*model.PGPKey{resultKey}, nil } // Index implements PGPKeyStorage interface for the mock. // See PGPKeyStorage interface for documentation. -func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*service.PGPkey, error) { +func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*model.PGPKey, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -160,13 +161,13 @@ func (m *InMemStorageMock) Index(ctx context.Context, q string) ([]*service.PGPk // Simulate case-insensitive substring match for regex. // For a more accurate mock, you'd use the 'regexp' package here. if strings.Contains(strings.ToLower(uid.UIDString), lowerQ) || - strings.Contains(strings.ToLower(uid.Email), lowerQ) { + strings.Contains(strings.ToLower(uid.Email), lowerQ) { matchedFingerprints[f] = true } } } - var resultKeys []*service.PGPkey + var resultKeys []*model.PGPKey for f := range matchedFingerprints { // Reuse GetKey logic from mock (which filters for verified UIDs) keys, err := m.GetKey(ctx, f) diff --git a/internal/repo/storage/postgresql/repository.go b/internal/repo/storage/postgresql/repository.go index 790a3df..9c958e7 100644 --- a/internal/repo/storage/postgresql/repository.go +++ b/internal/repo/storage/postgresql/repository.go @@ -5,8 +5,8 @@ import ( "database/sql" "errors" "fmt" - repo "gadrid/internal/repo/storage" - service "gadrid/internal/service/storage" + "hkp-clavis/internal/model" + repo "hkp-clavis/internal/repo/storage" "strings" "time" @@ -19,13 +19,71 @@ import ( //go:embed structure.sql var db_struct string +var ( + insertUid = ` + insert into pgp_uids(fingerprint, uid, email, verified, verification_token, token_expires_at) + values($1, $2, $3, $4, $5, $6) + on conflict (fingerprint, uid) do nothing; + ` + selectUid = ` + select uid, verification_token, token_expires_at, verified + from pgp_uids + where fingerprint = $1 and email = $2; + ` + updateUidVerify = ` + update pgp_uids + set verified = true, + verification_token = null, + token_expires_at = null + where fingerprint = $1 and email = $2 and verification_token = $3; + ` + fetchUidVerifed = ` + select uid, email, verification_token, token_expires_at, verified + from pgp_uids + where fingerprint = $1 and verified = true; + ` + insertKey = ` + insert into pgp_keys(fingerprint, packet, revoked, update_time) + values($1, $2, $3, now()) + on conflict (fingerprint) do update set + packet = excluded.packet, + revoked = excluded.revoked, + update_time = now(); + ` + selectKeyByFingerprint = ` + select fingerprint, packet, revoked, update_time + from pgp_keys + where fingerprint ~* $1; + ` + indexKeyByString = ` + SELECT + k.fingerprint, k.packet, k.revoked, k.update_time, + u.uid, u.email, u.verified, u.verification_token, u.token_expires_at + FROM pgp_keys k + JOIN pgp_uids u ON k.fingerprint = u.fingerprint + WHERE k.fingerprint IN ( + SELECT DISTINCT fingerprint + FROM pgp_uids + WHERE (uid ~* $1 OR email ~* $1 OR fingerprint ~* $1) AND verified = true + ) + ORDER BY k.fingerprint; + ` + cleanupStaleKeys = ` + delete from pgp_keys pk + where not exists ( + select 1 from pgp_uids pu where pu.fingerprint = pk.fingerprint + ); + ` +) + type PostgresqlStorageRepo struct { - poll *pgxpool.Pool + poll *pgxpool.Pool + keyMngr *model.KeyManager } // Get context, url // Return postgresql repository -func New(ctx context.Context, pool *pgxpool.Pool) repo.StorageRepositotyInterface { +func New(ctx context.Context, pool *pgxpool.Pool, keyManager *model.KeyManager) repo.StorageRepositotyInterface { _, err := pool.Exec(ctx, db_struct) if err != nil { panic(err) @@ -33,10 +91,12 @@ func New(ctx context.Context, pool *pgxpool.Pool) repo.StorageRepositotyInterfac return PostgresqlStorageRepo{ poll: pool, + // Allocation manager + keyMngr: keyManager, } } -func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*service.PGPkey) error { +func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*model.PGPKey) error { tx, err := s.poll.Begin(ctx) if err != nil { return fmt.Errorf("error postgresql: AddKey: begin transaction: %w", err) @@ -46,27 +106,15 @@ func (s PostgresqlStorageRepo) AddKey(ctx context.Context, keys []*service.PGPke for _, k := range keys { fingerprint := k.Fingerprint // Upsert pgp_keys - _, err = tx.Exec(ctx, ` - insert into pgp_keys(fingerprint, packet, revoked, update_time) - values($1, $2, $3, now()) - on conflict (fingerprint) do update set - packet = excluded.packet, - revoked = excluded.revoked, - update_time = now(); - `, fingerprint, k.Packet, k.Revoked) + _, err = tx.Exec(ctx, insertKey, fingerprint, k.Packet, k.Revoked) if err != nil { return fmt.Errorf("error postgresql: AddKey: upsert key %s: %w", fingerprint, err) } // Upsert pgp_uids batch := &pgx.Batch{} - insertUIDQuery := ` - insert into pgp_uids(fingerprint, uid, email, verified, verification_token, token_expires_at) - values($1, $2, $3, $4, $5, $6) - on conflict (fingerprint, uid) do nothing; - ` for _, u := range k.Uids { - batch.Queue(insertUIDQuery, fingerprint, u.UIDString, u.Email, u.Verify, u.Token, u.TokenExpires) + batch.Queue(insertUid, fingerprint, u.UIDString, u.Email, u.Verify, u.Token, u.TokenExpires) } br := tx.SendBatch(ctx, batch) @@ -97,11 +145,7 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email var tokenExpiresAt sql.NullTime var isVerified bool - row := tx.QueryRow(ctx, ` - select uid, verification_token, token_expires_at, verified - from pgp_uids - where fingerprint = $1 and email = $2; - `, fingerprint, email) + row := tx.QueryRow(ctx, selectUid, fingerprint, email) if err = row.Scan(&storedUID, &storedToken, &tokenExpiresAt, &isVerified); err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -124,13 +168,7 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email } // UPDATE the UID status. - result, err := tx.Exec(ctx, ` - update pgp_uids - set verified = true, - verification_token = null, - token_expires_at = null - where fingerprint = $1 and email = $2 and verification_token = $3; - `, fingerprint, email, token) + result, err := tx.Exec(ctx, updateUidVerify, fingerprint, email, token) if err != nil { return fmt.Errorf("error postgresql: VerifyUID: failed to update UID verification status: %w", err) } @@ -146,124 +184,115 @@ func (s PostgresqlStorageRepo) VerifyUID(ctx context.Context, fingerprint, email return tx.Commit(ctx) } -func (s PostgresqlStorageRepo) GetKey(ctx context.Context, fngprt string) ([]*service.PGPkey, error) { - var packet string - var fingerprint string - var revoked bool - var update_time time.Time +func (s PostgresqlStorageRepo) GetKey(ctx context.Context, fngprt string) (res []*model.PGPKey, err error) { + pgpKey := s.keyMngr.Get() - row := s.poll.QueryRow(ctx, ` - select fingerprint, packet, revoked, update_time - from pgp_keys - where fingerprint ~* $1; - `, fngprt) + defer func() { + if err != nil { + s.keyMngr.Release(pgpKey) + } + }() - if err := row.Scan(&fingerprint, &packet, &revoked, &update_time); err != nil { + row := s.poll.QueryRow(ctx, selectKeyByFingerprint, fngprt) + + if err = row.Scan(&pgpKey.Fingerprint, &pgpKey.Packet, &pgpKey.Revoked, &pgpKey.UpdateTime); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, repo.ErrKeyNotFound } - return nil, fmt.Errorf("error postgresql: GetKey: failed to scan key data for fingerprint %s: %w", fngprt, err) + return nil, fmt.Errorf("error postgresql: GetKey: scan error: %w", err) } - if !strings.Contains(fingerprint, fngprt) { + if !strings.Contains(pgpKey.Fingerprint, fngprt) { return nil, repo.ErrKeyNotFound } - // Fetch all VERIFIED UIDs for this key. - rows, err := s.poll.Query(ctx, ` - select uid, email, verification_token, token_expires_at, verified - from pgp_uids - where fingerprint = $1 and verified = true; - `, fngprt) + rows, err := s.poll.Query(ctx, fetchUidVerifed, pgpKey.Fingerprint) if err != nil { - return nil, fmt.Errorf("error postgresql: GetKey: failed to query UIDs for key %s: %w", fngprt, err) + return nil, fmt.Errorf("error postgresql: GetKey: query uids error: %w", err) } defer rows.Close() - var uids []*service.PGPUid for rows.Next() { - - uid := &service.PGPUid{} + uid := s.keyMngr.GetUid() var token sql.NullString var tokenExpiresAt sql.NullTime - if err := rows.Scan(&uid.UIDString, &uid.Email, &token, &tokenExpiresAt, &uid.Verify); err != nil { - return nil, fmt.Errorf("error postgresql: GetKey: failed to scan UID row for key %s: %w", fngprt, err) + + if err = rows.Scan(&uid.UIDString, &uid.Email, &token, &tokenExpiresAt, &uid.Verify); err != nil { + s.keyMngr.ReleaseUid(uid) + return nil, err } uid.Token = token.String uid.TokenExpires = tokenExpiresAt.Time - - uids = append(uids, uid) - + pgpKey.Uids = append(pgpKey.Uids, uid) } if err = rows.Err(); err != nil { - return nil, fmt.Errorf("error postgresql: GetKey: error after iterating UIDs for key %s: %w", fngprt, err) + return nil, err } - pgpKey := &service.PGPkey{ - Fingerprint: fngprt, - UpdateTime: update_time, - Packet: packet, - Revoked: revoked, - Uids: uids, // Only verified UIDs included - } - return []*service.PGPkey{pgpKey}, nil + return []*model.PGPKey{pgpKey}, nil } -func (s PostgresqlStorageRepo) Index(ctx context.Context, q string) ([]*service.PGPkey, error) { - // Search for matching fingerprints based on the query 'q'. - fingerprintRows, err := s.poll.Query(ctx, ` - select distinct fingerprint - from pgp_uids - where (uid ~* $1 or email ~* $1 or fingerprint ~* $1) and verified = true; - `, q) +func (s PostgresqlStorageRepo) Index(ctx context.Context, q string) (resultKeys []*model.PGPKey, err error) { + rows, err := s.poll.Query(ctx, indexKeyByString, q) if err != nil { - return nil, fmt.Errorf("error postgresql: Index: failed to query matching fingerprints for '%s': %w", q, err) + return nil, fmt.Errorf("postgresql: Index query failed: %w", err) } - defer fingerprintRows.Close() + defer rows.Close() - var matchingFingerprints []string - for fingerprintRows.Next() { - var f string - if err := fingerprintRows.Scan(&f); err != nil { - return nil, fmt.Errorf("error postgresql: Index: failed to scan fingerprint: %w", err) - } - matchingFingerprints = append(matchingFingerprints, f) - } - if err = fingerprintRows.Err(); err != nil { - return nil, fmt.Errorf("error postgresql: Index: error after iterating fingerprints: %w", err) - } - - if len(matchingFingerprints) == 0 { - return []*service.PGPkey{}, nil - } - - // For each matching fingerprint, use GetKey to retrieve the full PGPKey object. - var resultKeys []*service.PGPkey - for _, fingerprint := range matchingFingerprints { - keys, err := s.GetKey(ctx, fingerprint) + defer func() { if err != nil { - if errors.Is(err, repo.ErrKeyNotFound) { - continue - } - return nil, fmt.Errorf("error postgresql: Index: failed to retrieve key %s via GetKey: %w", fingerprint, err) + s.keyMngr.Release(resultKeys...) } - if len(keys) > 0 { - resultKeys = append(resultKeys, keys[0]) + }() + + var currentKey *model.PGPKey + + for rows.Next() { + var fng, packet string + var revoked bool + var updateTime time.Time + + uidObj := s.keyMngr.GetUid() + var token sql.NullString + var tokenExpiresAt sql.NullTime + + err = rows.Scan( + &fng, &packet, &revoked, &updateTime, + &uidObj.UIDString, &uidObj.Email, &uidObj.Verify, &token, &tokenExpiresAt, + ) + if err != nil { + s.keyMngr.ReleaseUid(uidObj) + return nil, err } + + uidObj.Token = token.String + uidObj.TokenExpires = tokenExpiresAt.Time + uidObj.Fingerprint = fng + + if currentKey == nil || currentKey.Fingerprint != fng { + currentKey = s.keyMngr.Get() + currentKey.Fingerprint = fng + currentKey.Packet = packet + currentKey.Revoked = revoked + currentKey.UpdateTime = updateTime + + resultKeys = append(resultKeys, currentKey) + } + + currentKey.Uids = append(currentKey.Uids, uidObj) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("postgresql: Index rows iteration error: %w", err) } return resultKeys, nil } + func (s PostgresqlStorageRepo) CleanupStaleKeys(ctx context.Context) (int, error) { - // The SQL query will delete keys from pgp_keys that have no corresponding entries in pgp_uids. - result, err := s.poll.Exec(ctx, ` - delete from pgp_keys pk - where not exists ( - select 1 from pgp_uids pu where pu.fingerprint = pk.fingerprint - ); - `) + result, err := s.poll.Exec(ctx, cleanupStaleKeys) if err != nil { return 0, fmt.Errorf("error postgresql: CleanupStaleKeys: failed to delete stale keys: %w", err) } diff --git a/internal/repo/storage/postgresql/test/postgresql_test.go b/internal/repo/storage/postgresql/test/postgresql_test.go index ae4f4d4..190a117 100644 --- a/internal/repo/storage/postgresql/test/postgresql_test.go +++ b/internal/repo/storage/postgresql/test/postgresql_test.go @@ -12,9 +12,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - repository "gadrid/internal/repo/storage" - psqlrepo "gadrid/internal/repo/storage/postgresql" - service "gadrid/internal/service/storage" + "hkp-clavis/internal/model" + repository "hkp-clavis/internal/repo/storage" + psqlrepo "hkp-clavis/internal/repo/storage/postgresql" ) //go:embed structure.sql @@ -25,21 +25,23 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) { defer pool.Close() defer cleanupTestDB(t, pool) - repo := psqlrepo.New(context.Background(), pool) + mgr := model.NewKeyManager() + + repo := psqlrepo.New(context.Background(), pool, mgr) ctx := context.Background() // --- Test Case 1: Add a new key --- t.Run("Add new key successfully", func(t *testing.T) { - key := &service.PGPkey{ + key := &model.PGPKey{ Fingerprint: "FINGERPRINT_NEW_KEY", Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----NEW_KEY_PACKET-----END PGP PUBLIC KEY BLOCK-----", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "New User ", Email: "new@example.com", Verify: false, Token: "token1", TokenExpires: time.Now().Add(time.Hour)}, }, } - err := repo.AddKey(ctx, []*service.PGPkey{key}) + err := repo.AddKey(ctx, []*model.PGPKey{key}) require.NoError(t, err) // Verify key was added @@ -60,27 +62,27 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) { // --- Test Case 2: Update an existing key (packet, revoked status) --- t.Run("Update existing key packet and revoked status", func(t *testing.T) { - initialKey := &service.PGPkey{ + initialKey := &model.PGPKey{ Fingerprint: "FINGERPRINT_EXISTING", Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----INITIAL_PACKET-----END PGP PUBLIC KEY BLOCK-----", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Existing User ", Email: "existing@example.com", Verify: false, Token: "initial_token", TokenExpires: time.Now().Add(time.Hour)}, }, } - err := repo.AddKey(ctx, []*service.PGPkey{initialKey}) + err := repo.AddKey(ctx, []*model.PGPKey{initialKey}) require.NoError(t, err) // Simulate an update: new packet, revoked true - updatedKey := &service.PGPkey{ + updatedKey := &model.PGPKey{ Fingerprint: "FINGERPRINT_EXISTING", Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----UPDATED_PACKET-----END PGP PUBLIC KEY BLOCK-----", Revoked: true, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Existing User ", Email: "existing@example.com", Verify: false, Token: "updated_token", TokenExpires: time.Now().Add(time.Hour * 2)}, }, } - err = repo.AddKey(ctx, []*service.PGPkey{updatedKey}) + err = repo.AddKey(ctx, []*model.PGPKey{updatedKey}) require.NoError(t, err) // Verify key was updated @@ -100,28 +102,28 @@ func TestPostgresqlStorageRepo_AddKey(t *testing.T) { // --- Test Case 3: Add new UID to existing key --- t.Run("Add new UID to existing key", func(t *testing.T) { - key := &service.PGPkey{ + key := &model.PGPKey{ Fingerprint: "FINGERPRINT_ADD_UID", Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----ADD_UID_KEY-----END PGP PUBLIC KEY BLOCK-----", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "First UID ", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)}, }, } - err := repo.AddKey(ctx, []*service.PGPkey{key}) + err := repo.AddKey(ctx, []*model.PGPKey{key}) require.NoError(t, err) // Add a second UID to the same key - updatedKey := &service.PGPkey{ + updatedKey := &model.PGPKey{ Fingerprint: "FINGERPRINT_ADD_UID", Packet: "-----BEGIN PGP PUBLIC KEY BLOCK-----ADD_UID_KEY_UPDATED-----END PGP PUBLIC KEY BLOCK-----", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "First UID ", Email: "first@example.com", Verify: false, Token: "token_first", TokenExpires: time.Now().Add(time.Hour)}, {UIDString: "Second UID ", Email: "second@example.com", Verify: false, Token: "token_second", TokenExpires: time.Now().Add(time.Hour)}, }, } - err = repo.AddKey(ctx, []*service.PGPkey{updatedKey}) + err = repo.AddKey(ctx, []*model.PGPKey{updatedKey}) require.NoError(t, err) // Verify both UIDs exist @@ -137,7 +139,9 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) { defer pool.Close() defer cleanupTestDB(t, pool) - repo := psqlrepo.New(context.Background(), pool) + mgr := model.NewKeyManager() + + repo := psqlrepo.New(context.Background(), pool, mgr) ctx := context.Background() // Add a key with an unverified UID @@ -146,12 +150,12 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) { uidToken := "verifytoken123" uidExpires := time.Now().Add(time.Hour) - err := repo.AddKey(ctx, []*service.PGPkey{ + err := repo.AddKey(ctx, []*model.PGPKey{ { Fingerprint: keyFingerprint, Packet: "...", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Verify Me <" + uidEmail + ">", Email: uidEmail, Verify: false, Token: uidToken, TokenExpires: uidExpires}, }, }, @@ -198,12 +202,12 @@ func TestPostgresqlStorageRepo_VerifyUID(t *testing.T) { expiredToken := "expiredtoken" expiredTime := time.Now().Add(-time.Hour) - err := repo.AddKey(ctx, []*service.PGPkey{ + err := repo.AddKey(ctx, []*model.PGPKey{ { Fingerprint: expiredFingerprint, Packet: "...", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Expired <" + expiredEmail + ">", Email: expiredEmail, Verify: false, Token: expiredToken, TokenExpires: expiredTime}, }, }, @@ -227,17 +231,18 @@ func TestPostgresqlStorageRepo_GetKey(t *testing.T) { defer pool.Close() defer cleanupTestDB(t, pool) - repo := psqlrepo.New(context.Background(), pool) + mgr := model.NewKeyManager() + repo := psqlrepo.New(context.Background(), pool, mgr) ctx := context.Background() // Add a key with mixed UIDs (verified and unverified) testFingerprint := "FINGERPRINT_GET_KEY" - err := repo.AddKey(ctx, []*service.PGPkey{ + err := repo.AddKey(ctx, []*model.PGPKey{ { Fingerprint: testFingerprint, Packet: "PACKET_DATA", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Verified User ", Email: "verified@example.com", Verify: false, Token: "tok_v", TokenExpires: time.Now().Add(time.Hour)}, {UIDString: "Unverified User ", Email: "unverified@example.com", Verify: false, Token: "tok_uv", TokenExpires: time.Now().Add(time.Hour)}, {UIDString: "Another Verified ", Email: "another@example.com", Verify: false, Token: "tok_av", TokenExpires: time.Now().Add(time.Hour)}, @@ -285,27 +290,29 @@ func TestPostgresqlStorageRepo_Index(t *testing.T) { defer pool.Close() defer cleanupTestDB(t, pool) - repo := psqlrepo.New(context.Background(), pool) + mgr := model.NewKeyManager() + + repo := psqlrepo.New(context.Background(), pool, mgr) ctx := context.Background() // Add multiple keys with various UIDs - keysToLoad := []*service.PGPkey{ + keysToLoad := []*model.PGPKey{ { Fingerprint: "FINGERPRINT_ALPHA", Packet: "alpha_packet", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Alpha User ", Email: "alpha@example.com", Verify: false, Token: "t1", TokenExpires: time.Now().Add(time.Hour)}, {UIDString: "Test Alpha ", Email: "testalpha@mail.com", Verify: false, Token: "t2", TokenExpires: time.Now().Add(time.Hour)}, }, }, { Fingerprint: "FINGERPRINT_BETA", Packet: "beta_packet", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Beta User ", Email: "beta@example.com", Verify: false, Token: "t3", TokenExpires: time.Now().Add(time.Hour)}, }, }, { Fingerprint: "FINGERPRINT_GAMMA", Packet: "gamma_packet", Revoked: false, - Uids: []*service.PGPUid{ + Uids: []*model.PGPUid{ {UIDString: "Gamma User ", Email: "gamma@example.com", Verify: false, Token: "t4", TokenExpires: time.Now().Add(time.Hour)}, {UIDString: "Test Gamma ", Email: "testgamma@mail.com", Verify: false, Token: "t5", TokenExpires: time.Now().Add(time.Hour)}, }, diff --git a/internal/service/mail/service.go b/internal/service/mail/service.go deleted file mode 100644 index 90d3492..0000000 --- a/internal/service/mail/service.go +++ /dev/null @@ -1,5 +0,0 @@ -package mail - -type MailServiceInterface interface { - SendMail(to []string, msg []byte) -} diff --git a/internal/service/storage/converter.go b/internal/service/storage/converter.go deleted file mode 100644 index f7429b5..0000000 --- a/internal/service/storage/converter.go +++ /dev/null @@ -1,111 +0,0 @@ -package storage - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "fmt" - "log" - "regexp" - "strings" - "time" - - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/ProtonMail/go-crypto/openpgp/armor" -) - -func extractEmail(fullNameWithEmail string) string { - re := regexp.MustCompile(`<([^>]+)>`) - matches := re.FindStringSubmatch(fullNameWithEmail) - if len(matches) > 1 { - return matches[1] - } - return "" -} - -// ConverterPgpToService converts a raw openpgp.EntityList into a slice of service.PGPkey objects. -// It parses key data, generates verification tokens for UIDs, and extracts email addresses. -// -// Parameters: -// -// entities: The openpgp.EntityList obtained from parsing an armored PGP block. -// -// Returns: -// -// []*PGPkey: A slice of converted PGPkey objects. Returns an empty (non-nil) slice if no valid entities are found. -// error: An error if any entity fails to be processed or contains invalid data. -func ConverterPgpToService(entities openpgp.EntityList) ([]*PGPkey, error) { - var pgpKeys []*PGPkey - - if entities == nil { - return pgpKeys, nil - } - - for i, entity := range entities { - // Test on correct data - if entity == nil { - log.Printf("Warning: ConverterPgpToService: encountered nil entity at index %d, skipping.", i) - continue - } - if entity.PrimaryKey == nil { - log.Printf("Warning: ConverterPgpToService: entity at index %d has no primary key, skipping.", i) - continue - } - - publicKeyBuf := bytes.NewBuffer(nil) - publicKeyWriter, err := armor.Encode(publicKeyBuf, openpgp.PublicKeyType, nil) - if err != nil { - return nil, fmt.Errorf("converter error: entity %d: failed to create armor encoder: %w", i, err) - } - - if err = entity.Serialize(publicKeyWriter); err != nil { - return nil, fmt.Errorf("converter error: entity %d: failed to serialize public key: %w", i, err) - } - publicKeyWriter.Close() - - // Write it - - pgpKey := PGPkey{ - Revoked: entity.Revoked(time.Now()), - UpdateTime: entity.PrimaryKey.CreationTime.UTC(), // <--- FIX IS HERE! Use PrimaryKey's CreationTime - Fingerprint: strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)), - Packet: publicKeyBuf.String(), - Uids: []*PGPUid{}, - } - - // Process each UID for the current entity - for _, uid := range entity.Identities { - if uid == nil || uid.UserId == nil { - log.Printf("Warning: ConverterPgpToService: encountered nil Identity or UserId for key %s, skipping.", pgpKey.Fingerprint) - continue - } - if uid.Name == "" { - log.Printf("Warning: ConverterPgpToService: encountered empty User ID string for key %s, skipping.", pgpKey.Fingerprint) - continue - } - - randBytes := make([]byte, 16) - if _, err := rand.Read(randBytes); err != nil { - return nil, fmt.Errorf("converter error: key %s: failed to generate random bytes for token: %w", pgpKey.Fingerprint, err) - } - token := hex.EncodeToString(randBytes) - - extractedEmail := extractEmail(uid.Name) - if extractedEmail == "" { - log.Printf("Warning: ConverterPgpToService: extracted empty email from UID '%s' for key %s, skipping UID.", uid.Name, pgpKey.Fingerprint) - continue - } - - pgpKey.Uids = append(pgpKey.Uids, &PGPUid{ - Verify: false, - UIDString: uid.Name, - Email: extractedEmail, - Token: token, - TokenExpires: time.Now().Add(time.Hour * 5), - Fingerprint: pgpKey.Fingerprint, - }) - } - pgpKeys = append(pgpKeys, &pgpKey) - } - return pgpKeys, nil -} diff --git a/internal/service/storage/interface.go b/internal/service/storage/interface.go index 8559e79..5dadb98 100644 --- a/internal/service/storage/interface.go +++ b/internal/service/storage/interface.go @@ -1,30 +1,15 @@ package storage import ( - "time" + "context" + "hkp-clavis/internal/model" "github.com/ProtonMail/go-crypto/openpgp" + hkp "github.com/emersion/go-openpgp-hkp" ) type StorageInterface interface { - AddKey(openpgp.EntityList) error - GetKeyByUID(uid string) openpgp.EntityList - GetKeyByFingerprint(fngr string) openpgp.EntityList -} - -type PGPkey struct { - Revoked bool - UpdateTime time.Time - Fingerprint string - Packet string - Uids []*PGPUid -} - -type PGPUid struct { - Verify bool - Token string - TokenExpires time.Time - Email string - UIDString string // "Full Name (comment) " - Fingerprint string + Add(ctx context.Context, el openpgp.EntityList) ([]*model.PGPKey, error) + Get(ctx context.Context, r *hkp.LookupRequest) (openpgp.EntityList, error) + Index(ctx context.Context, r *hkp.LookupRequest) ([]hkp.IndexKey, error) } diff --git a/internal/service/storage/serv/service.go b/internal/service/storage/serv/service.go index bb8dc9e..a2d60ee 100644 --- a/internal/service/storage/serv/service.go +++ b/internal/service/storage/serv/service.go @@ -1,14 +1,13 @@ -package storage +package serv import ( "context" - "errors" "fmt" "log" - "strings" - storeRepo "gadrid/internal/repo/storage" - service "gadrid/internal/service/storage" + "hkp-clavis/internal/model" + storeRepo "hkp-clavis/internal/repo/storage" + service "hkp-clavis/internal/service/storage" "github.com/ProtonMail/go-crypto/openpgp" hkp "github.com/emersion/go-openpgp-hkp" @@ -18,79 +17,82 @@ type StorageService struct { hkp.Adder hkp.Lookuper // mailService interface{} - storage storeRepo.StorageRepositotyInterface + storage storeRepo.StorageRepositotyInterface + config StorageServiceConfig + keyManager *model.KeyManager } -func New(storageRepository storeRepo.StorageRepositotyInterface) StorageService { +type StorageServiceConfig struct { + defaultVerify bool +} + +func New(storageRepository storeRepo.StorageRepositotyInterface, keyManager *model.KeyManager, defaultVerifyValue bool) service.StorageInterface { return StorageService{ - storage: storageRepository, + storage: storageRepository, + keyManager: keyManager, + config: StorageServiceConfig{ + defaultVerify: defaultVerifyValue, + }, + + // Allocation manager } } -func (s StorageService) Add(el openpgp.EntityList) error { - fmt.Println(el) - pgpKeys, err := service.ConverterPgpToService(el) // Assuming ConverterPgpToService might return an error now. +func (s StorageService) Add(ctx context.Context, el openpgp.EntityList) ([]*model.PGPKey, error) { + pgpKeys, release, err := s.keyManager.ConverterPgpToService(el, s.config.defaultVerify) // Assuming ConverterPgpToService might return an error now. + defer release() + if err != nil { - return fmt.Errorf("service error: Add: failed to convert PGP entities: %w", err) + return []*model.PGPKey{}, fmt.Errorf("service error: Add: failed to convert PGP entities: %w", err) } if len(pgpKeys) == 0 { - return nil // No keys to add, nothing to do. + return []*model.PGPKey{}, nil // No keys to add, nothing to do. } - // 2. Pass the context from this service method down to the storage layer. - // 3. ALWAYS check the error returned by the storage layer. - err = s.storage.AddKey(context.Background(), pgpKeys) // Pass context here + err = s.storage.AddKey(ctx, pgpKeys) // Pass context here if err != nil { - return fmt.Errorf("service error: Add: failed to add keys to storage: %w", err) + return []*model.PGPKey{}, fmt.Errorf("service error: Add: failed to add keys to storage: %w", err) } - return nil // Successfully processed + return pgpKeys, nil } -// Get key by fingerprint -// In Specification "op=get" fingerprint and uid -// FUTUE TE IPSUM specification! Only fingerprint search! - -func (s StorageService) Get(req *hkp.LookupRequest) (openpgp.EntityList, error) { - - req.Search = strings.Trim(req.Search, "0x") - - if len(req.Search) <= 15 { - return nil, fmt.Errorf("service error: Get: Low entropy string for search") - } - - dbKeys, err := s.storage.GetKey(context.Background(), req.Search) +func (s StorageService) Get(ctx context.Context, req *hkp.LookupRequest) (openpgp.EntityList, error) { + dbKeys, err := s.storage.Index(ctx, req.Search) if err != nil { - if errors.Is(err, storeRepo.ErrKeyNotFound) { - return nil, fmt.Errorf("service error: Get: key %s not found: %w", req.Search, err) - } - return nil, fmt.Errorf("service error: Get: db request failed for key %s: %w", req.Search, err) + return nil, fmt.Errorf("service error: Get: search failed: %w", err) } if len(dbKeys) == 0 { - return nil, fmt.Errorf("service error: Get: key %s not found (repository returned empty list): %w", req.Search, storeRepo.ErrKeyNotFound) + return nil, storeRepo.ErrKeyNotFound } - dbKey := dbKeys[0] + defer s.keyManager.Release(dbKeys...) - filteredEntity, err := s.sanitizeAndFilterKey(dbKey) - if err != nil { - return nil, fmt.Errorf("service error: Get: failed to sanitize and filter key %s: %w", dbKey.Fingerprint, err) + var finalEntityList openpgp.EntityList + + for _, dbKey := range dbKeys { + filteredEntities, err := model.SanitizeAndFilterKey(dbKey) + if err != nil { + log.Printf("Warning: Get: failed to sanitize key %s: %v", dbKey.Fingerprint, err) + continue + } + finalEntityList = append(finalEntityList, filteredEntities...) } - return filteredEntity, nil + if len(finalEntityList) == 0 { + return nil, storeRepo.ErrKeyNotFound + } + + return finalEntityList, nil } -func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) { - - if len(req.Search) < 5 { - return nil, fmt.Errorf("service error: Index: short string for search") - } - - dbKeys, err := s.storage.Index(context.Background(), req.Search) // Pass ctx here +func (s StorageService) Index(ctx context.Context, req *hkp.LookupRequest) ([]hkp.IndexKey, error) { + dbKeys, err := s.storage.Index(ctx, req.Search) if err != nil { - return nil, fmt.Errorf("service error: Index: db index request for '%s': %w", req.Search, err) // Include original error + return nil, fmt.Errorf("service error: Index: db index request for '%s': %w", req.Search, err) } + defer s.keyManager.Release(dbKeys...) if len(dbKeys) == 0 { return nil, nil @@ -98,7 +100,7 @@ func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) { var respIndex []hkp.IndexKey for _, dbKey := range dbKeys { - filteredEntity, err := s.sanitizeAndFilterKey(dbKey) + filteredEntity, err := model.SanitizeAndFilterKey(dbKey) if err != nil { log.Printf("Warning: service error: Index: failed to sanitize and filter key %s: %v, skipping.", dbKey.Fingerprint, err) continue // Skip this problematic key @@ -120,34 +122,3 @@ func (s StorageService) Index(req *hkp.LookupRequest) ([]hkp.IndexKey, error) { return respIndex, nil } - -func (s StorageService) sanitizeAndFilterKey(dbKey *service.PGPkey) (openpgp.EntityList, error) { - - parsedEntities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(dbKey.Packet)) - if err != nil { - return nil, fmt.Errorf("failed to parse key packet for %s: %w", dbKey.Fingerprint, err) - } - - if len(parsedEntities) == 0 { - return nil, fmt.Errorf("parsed key packet for %s yielded no entities", dbKey.Fingerprint) - } - var respEntities openpgp.EntityList - for _, e := range parsedEntities { - if e == nil { - return nil, fmt.Errorf("no entity with matching fingerprint %s found in parsed packet from DB", dbKey.Fingerprint) - } - verifiedUIDStringsFromDB := make(map[string]struct{}) - for _, dbUID := range dbKey.Uids { - verifiedUIDStringsFromDB[dbUID.UIDString] = struct{}{} - } - filteredIdentities := make(map[string]*openpgp.Identity) - for identityKey, identity := range e.Identities { - if _, isVerified := verifiedUIDStringsFromDB[identity.Name]; isVerified { - filteredIdentities[identityKey] = identity // Keep this identity - } - } - e.Identities = filteredIdentities - respEntities = append(respEntities, e) - } - return respEntities, nil -} diff --git a/internal/service/verify/serv/service.go b/internal/service/verify/serv/service.go new file mode 100644 index 0000000..3354e59 --- /dev/null +++ b/internal/service/verify/serv/service.go @@ -0,0 +1,15 @@ +package serv + +import ( + "context" + "hkp-clavis/internal/model" + service "hkp-clavis/internal/service/verify" +) + +type VerifyMailService struct { + service.VerifyServiceInterface +} + +func (s VerifyMailService) SendVerifyMessage(ctx context.Context, el []*model.PGPKey) error { + return nil +} diff --git a/internal/service/verify/service.go b/internal/service/verify/service.go new file mode 100644 index 0000000..314d05b --- /dev/null +++ b/internal/service/verify/service.go @@ -0,0 +1,10 @@ +package mail + +import ( + "context" + "hkp-clavis/internal/model" +) + +type VerifyServiceInterface interface { + SendVerifyMessage(ctx context.Context, el []*model.PGPKey) error +} diff --git a/main b/main deleted file mode 100755 index 90450dd..0000000 Binary files a/main and /dev/null differ diff --git a/pkg/mail/NOT_WORK_DO_NOT_TOUCH b/pkg/mail/NOT_WORK_DO_NOT_TOUCH new file mode 100644 index 0000000..e69de29 diff --git a/pkg/mail/smtp/pool/smtp.go b/pkg/mail/smtp/pool/smtp.go new file mode 100644 index 0000000..77b1ead --- /dev/null +++ b/pkg/mail/smtp/pool/smtp.go @@ -0,0 +1,195 @@ +package pool + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "net/smtp" + "sync" + "sync/atomic" + "time" +) + +type Message struct { + From string + To []string + Message bytes.Buffer + inProcess bool +} + +type MessageTransaction struct { + done bool + key string + msg *Message + pool *MessagePool +} + +func (s MessageTransaction) Done() { + s.done = true +} + +func (s MessageTransaction) Close() { + if s.done { + s.pool.Del(s.key) + return + } + s.msg.inProcess = false +} + +type MessagePool struct { + m *sync.Mutex + messagePool map[string]*Message +} + +func (p MessagePool) Put(m *Message) { + if m == nil { + return + } + p.m.Lock() + + h := sha256.New() + h.Write(m.Message.Bytes()) + k := string(h.Sum(nil)) + + p.messagePool[k] = m + p.m.Unlock() +} + +func (p MessagePool) Del(key string) { + p.m.Lock() + delete(p.messagePool, key) + p.m.Unlock() +} + +func (p MessagePool) Begin() chan MessageTransaction { + c := make(chan MessageTransaction) + go func() { + for k, m := range p.messagePool { + if !m.inProcess { + p.m.Lock() + m.inProcess = true + c <- MessageTransaction{ + done: false, + key: k, + msg: m, + pool: &p, + } + p.m.Unlock() + } + close(c) + } + }() + return c +} + +type PoolConfig struct { + CountOfConnection int32 + Identity, Username, Password string + Host string +} + +type SMTPPool struct { + config PoolConfig + countOfConnection int32 + connections chan *smtp.Client + MessagePool *MessagePool +} + +// Get SMTPPoolConfig return SMTPPool +func NewSMTPPool(conf PoolConfig) (*SMTPPool, error) { + auth := smtp.PlainAuth(conf.Identity, conf.Username, conf.Password, conf.Host) + + pool := &SMTPPool{ + config: conf, + connections: make(chan *smtp.Client, conf.CountOfConnection), + countOfConnection: 0, + } + + go pool.healthChecker(auth) + + // Init Connections + for range conf.CountOfConnection { + c, err := newClient(conf, auth) + if err != nil { + return nil, fmt.Errorf("pkg smtp pool: NewSMTPPool: Init Connections: %w", err) + } + // Add to pool + pool.connections <- c + pool.countOfConnection++ + } + + return pool, nil +} + +func newClient(conf PoolConfig, auth smtp.Auth) (*smtp.Client, error) { + c, err := smtp.Dial(conf.Host) + if err != nil { + return nil, fmt.Errorf("newClient: %w", err) + } + + if err = c.Hello(conf.Identity); err != nil { + return nil, err + } + // Check TLS + if ok, _ := c.Extension("STARTTLS"); ok { + config := &tls.Config{ServerName: conf.Host} + if err = c.StartTLS(config); err != nil { + return nil, fmt.Errorf("newClient: %w", err) + } + } + // Auth + if ok, _ := c.Extension("AUTH"); ok { + if auth == nil { + return nil, errors.New("newClient: auth is nil") + } + if err := c.Auth(auth); err != nil { + return nil, fmt.Errorf("newClient: %w", err) + } + } + + if err := c.Noop(); err != nil { + return nil, fmt.Errorf("newClient: c.Noop() error: %w", err) + } + return c, nil +} + +// Daemon. Doing Helth-Check and Auto-Heal of SMTPPool +func (p *SMTPPool) healthChecker(auth smtp.Auth) { + ticker := time.NewTicker(30 * time.Second) + for range ticker.C { + + // Auto-Heal Pool + + current := atomic.LoadInt32(&p.countOfConnection) + needed := int32(p.config.CountOfConnection) - current + if needed > 0 { + for range p.config.CountOfConnection - p.countOfConnection { + newConn, err := newClient(p.config, auth) + if err != nil { + fmt.Println("WARNING: pkg smtp pool: healthChecker: Create connection: %w", err) + continue + } + p.connections <- newConn + atomic.AddInt32(&p.countOfConnection, 1) + } + } + + select { + case conn := <-p.connections: + if err := conn.Noop(); err != nil { + atomic.AddInt32(&p.countOfConnection, -1) + conn.Close() + } else { + p.connections <- conn + } + default: + // All Conns is busy + } + } +} + +func (p *SMTPPool) SendMail(from string, to []string, mess []byte) error { + return nil +}