├── .dockerignore ├── .env.example ├── .github └── workflows │ └── ci-cd.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── app │ └── main.go ├── config ├── env.go └── jwt.go ├── docker-compose.yml ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── email ├── assets │ ├── header.png │ └── logo.png ├── embed.go ├── renderer.go └── templates │ ├── base.html │ ├── components │ ├── footer.html │ ├── header.html │ └── logo.html │ └── transactional │ └── content.html ├── go.mod ├── go.sum ├── internal ├── delivery │ ├── http │ │ ├── handler │ │ │ ├── admin_handler.go │ │ │ ├── auth_handler.go │ │ │ ├── health_handler.go │ │ │ ├── url_handler.go │ │ │ └── verification_handler.go │ │ └── router │ │ │ └── router.go │ └── middleware │ │ ├── admin_only.go │ │ ├── ip_whitelist.go │ │ ├── jwt_auth.go │ │ ├── jwt_verification.go │ │ ├── metrics.go │ │ └── request_logger.go ├── domain │ ├── entity │ │ ├── url.go │ │ └── user.go │ ├── request │ │ ├── auth.go │ │ ├── url.go │ │ └── verification.go │ └── response │ │ ├── admin.go │ │ ├── auth.go │ │ ├── error.go │ │ ├── health.go │ │ ├── success.go │ │ └── url.go ├── health │ ├── state.go │ └── watchdog.go ├── infrastructure │ ├── cache │ │ └── redis.go │ ├── db │ │ └── db.go │ └── mail │ │ └── mail.go ├── jobs │ └── click_flush_job.go ├── metrics │ └── prometheus.go ├── repository │ ├── url_repository.go │ └── user_repository.go ├── seed │ └── admin.go ├── service │ ├── click_flusher_service.go │ ├── mail_service.go │ ├── url_service.go │ └── user_service.go └── system │ ├── shutdown.go │ └── signal.go ├── pkg ├── logger │ └── logger.go └── utils │ ├── generator.go │ ├── ip.go │ ├── redis_click.go │ ├── redis_helper.go │ └── reserved.go └── prometheus ├── alertmanager └── alertmanager.tmpl.yml ├── prometheus.yml └── rules.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git files 2 | .git 3 | .gitignore 4 | 5 | # IDE configs 6 | .idea/ 7 | .vscode/ 8 | *.swp 9 | 10 | # Mac OS 11 | .DS_Store 12 | 13 | # Go build artifacts 14 | bin/ 15 | tmp/ 16 | dist/ 17 | 18 | # Local environment files 19 | .env 20 | *.local -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ##### Application ##### 2 | APP_PORT=4000 # HTTP server port 3 | DOMAIN=yourdomain.com 4 | 5 | 6 | ##### Database ##### 7 | POSTGRES_NAME=pg-shortener 8 | POSTGRES_PORT=5432 9 | POSTGRES_DB=urlShortener 10 | POSTGRES_USER=username 11 | POSTGRES_PASSWORD=password 12 | 13 | 14 | ##### Postgres Exporter ##### 15 | DATA_SOURCE_NAME=postgresql://your_db_user:your_db_password@pg-shortener:5432/your_db_name?sslmode=disable 16 | 17 | 18 | ##### Cache & Rate Limits ##### 19 | DAILY_CLICK_CACHE_THRESHOLD=100 20 | REDIS_ADDR=redis-shortener:6379 21 | CLICK_THRESHOLD=10 # clicks before caching 22 | RATE_LIMIT=60 # requests per minute 23 | 24 | 25 | ##### Security ##### 26 | JWT_SECRET="helloWorld123" 27 | IP_WHITELIST="127.0.0.1,::1" 28 | SWAGGER_PROTECT=true 29 | METRICS_PROTECT=false 30 | TRUST_PROXY_IPS= 0.0.0.0/0 #traefik + app/middleware 31 | 32 | ##### Basic auth: admin / cem123 ##### Generated via: docker run --rm httpd:2-alpine htpasswd -nbB admin cem123 ##### then replace each $ → $$ ##### 33 | BASIC_AUTH_USERS=admin:$$2y$$05$$soduOLxYFdUd3eo14csZoO6SyyVBtp1Jk/aHahQpGi9VTU2N.tc6K 34 | 35 | 36 | #### Rate Limit Middleware #### 37 | RATE_LIMIT_AVERAGE=1 38 | RATE_LIMIT_PERIOD=3s 39 | RATE_LIMIT_BURST=1 40 | 41 | 42 | ##### Email / SMTP ##### 43 | SMTP_HOST=mail.domain.com.tr 44 | SMTP_PORT=587 45 | SMTP_USER=mail@domain.com.tr 46 | SMTP_PASS="password" 47 | SMTP_FROM="Url Shortener " 48 | 49 | 50 | ##### Alerting ##### 51 | TEAM_EMAIL=ops@domain.com.tr # Alert notifications 52 | ALERT_LOW_REQ_RATE=5 53 | ALERT_HIGH_REQ_RATE=500 54 | 55 | 56 | ##### Admin Seeding ##### 57 | ADMIN_EMAIL=admin@domain.com 58 | ADMIN_PASSWORD=adminPassword 59 | 60 | 61 | ##### Grafana Admin ##### 62 | GF_SECURITY_ADMIN_USER=admin 63 | GF_SECURITY_ADMIN_PASSWORD=admin -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ "prod" ] 6 | pull_request: 7 | branches: [ "prod" ] 8 | 9 | env: 10 | DOCKER_IMAGE: cemakan/url-shortener 11 | CONTAINER_NAME: url-shortener 12 | REGISTRY: docker.io 13 | 14 | jobs: 15 | build-and-test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Set up QEMU (for multi-arch) 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Log in to Docker Hub 28 | uses: docker/login-action@v3 29 | with: 30 | username: "cemakan" 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Build Docker image with prod tag 34 | run: | 35 | docker build -t $DOCKER_IMAGE:latest . 36 | 37 | - name: Push Docker image 38 | run: | 39 | docker push $DOCKER_IMAGE:latest 40 | 41 | deploy: 42 | runs-on: ubuntu-latest 43 | needs: build-and-test 44 | steps: 45 | - name: Deploy to Production (SSH) 46 | uses: appleboy/ssh-action@v1 47 | with: 48 | host: ${{ secrets.PROD_HOST }} 49 | username: ${{ secrets.PROD_USER }} 50 | key: ${{ secrets.PROD_SSH_KEY }} 51 | port: 22 52 | script: | 53 | cd /opt/app/shortener 54 | docker compose pull 55 | docker compose up -d -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # env file 22 | .env 23 | 24 | # log file records 25 | logs/ 26 | 27 | # geetkepp 28 | *.gitkeep 29 | 30 | # OS generated files 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # IDE/editor folders 35 | .idea/ 36 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------- Stage 1: Build ---------- 2 | FROM golang:1.23-alpine AS builder 3 | 4 | RUN apk add --no-cache git ca-certificates 5 | 6 | WORKDIR /app 7 | 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | COPY . . 12 | 13 | # Copy assets explicitly (optional since COPY . . includes it) 14 | COPY ./email/assets /app/email/assets 15 | 16 | RUN go build -o url-shortener ./cmd/app/main.go 17 | 18 | # ---------- Stage 2: Run ---------- 19 | FROM alpine:latest 20 | 21 | RUN apk add --no-cache ca-certificates 22 | 23 | WORKDIR /app 24 | 25 | # Copy binary 26 | COPY --from=builder /app/url-shortener /app/url-shortener 27 | 28 | # Copy email assets 29 | COPY --from=builder /app/email/assets /app/email/assets 30 | 31 | EXPOSE 3000 32 | 33 | ENTRYPOINT ["/app/url-shortener"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 CemAkan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | COMPOSE := docker compose 4 | 5 | .PHONY: build up down restart logs prune 6 | 7 | up: 8 | $(COMPOSE) up -d 9 | 10 | down: 11 | $(COMPOSE) down 12 | 13 | restart: down up 14 | 15 | build: 16 | $(COMPOSE) build 17 | 18 | logs: 19 | $(COMPOSE) logs -f --tail=100 20 | 21 | prune: 22 | docker system prune -f --volumes 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | URL Shortener Logo 4 |

5 | 6 |

URL Shortener API

7 |

8 | A production-grade, secure, and extensible backend for high-performance URL shortening. 9 |

10 | 11 | --- 12 | 13 | ## Overview 14 | 15 | URL Shortener is a containerized, enterprise-ready backend API for managing short URLs, built with Go and following clean architecture principles. It integrates PostgreSQL, Redis, Prometheus, Grafana, Alertmanager, and Traefik. The system emphasizes scalability, observability, security, and operational maintainability. 16 | 17 | This project includes no frontend. It is designed to be integrated into existing platforms (web, mobile, CLI) or to run as a dedicated microservice. 18 | 19 | --- 20 | 21 | ## Key Features 22 | 23 | - REST API for shortening URLs (with optional custom aliases) 24 | - Secure user registration, login, and JWT-based authentication 25 | - Email verification and password reset using branded HTML templates 26 | - Admin-only endpoints for managing users and URLs 27 | - Click batching system using PostgreSQL and Redis 28 | - Real-time health check API and periodic background health verifier 29 | - Per-IP rate limiting via Traefik middleware 30 | - Prometheus metrics and Grafana dashboards 31 | - Email alerts for system and application-level anomalies 32 | - Per-component structured logging 33 | - GitHub Actions-based CI/CD and Docker Hub deployment 34 | - Secure HTTPS routing using Traefik with automatic TLS 35 | 36 | --- 37 | 38 | ## Architecture 39 | 40 | | Component | Description | 41 | |--------------------|---------------------------------------------------------| 42 | | `url-shortener` | Go-based REST API | 43 | | `postgres` | Persistent relational database | 44 | | `redis` | Caching layer for hot URLs | 45 | | `prometheus` | Metrics collection and alert rule engine | 46 | | `grafana` | Dashboard visualization | 47 | | `alertmanager` | Alert delivery engine (email notifications) | 48 | | `node_exporter` | Host-level metrics (CPU, RAM, Disk) | 49 | | `postgres_exporter` | PostgreSQL performance metrics for Prometheus | 50 | | `traefik` | HTTPS reverse proxy, rate limiter, and router | 51 | 52 | All services are managed via Docker Compose and isolated with internal Docker networks. 53 | 54 | --- 55 | 56 | ## API Documentation 57 | 58 | The API is fully documented using Swagger (OpenAPI 3.0) via [swaggo/swag](https://github.com/swaggo/swag). 59 | 60 | **Live Docs:** 61 | [API Swagger Live Documentation](https://cemakan.com.tr/api/docs/index.html) 62 | 63 | The documentation includes: 64 | - All available endpoints and methods 65 | - Field schemas and validation rules 66 | - Authentication structure (JWT) 67 | - Example requests and responses 68 | - Error responses and status codes 69 | 70 | --- 71 | 72 | ## Security 73 | 74 | - JWT Bearer tokens for all authenticated endpoints 75 | - IP allowlisting and Basic Auth for: 76 | - `/metrics` 77 | - `/grafana` 78 | - `/prometheus` 79 | - `/alert` 80 | - `/api/docs` 81 | - Passwords stored using bcrypt hashing 82 | - Auto-seeding of an admin account on first launch if none exists 83 | - All traffic securely routed via Traefik with TLS (Let's Encrypt or manual certs) 84 | 85 | --- 86 | 87 | ## Monitoring & Health 88 | 89 | - `/health` endpoint returns live JSON status of: 90 | - PostgreSQL 91 | - Redis 92 | - SMTP 93 | - Background health checker runs periodically 94 | - Prometheus scrapes metrics from: 95 | - Application 96 | - Redis 97 | - PostgreSQL 98 | - Node exporter 99 | - Alertmanager triggers alerts via email based on thresholds or failures 100 | - Grafana dashboards provide visual monitoring for all systems 101 | 102 | ### Default Alerts 103 | 104 | - CPU usage above 80% 105 | - Memory usage above 85% 106 | - Redis, PostgreSQL, or SMTP service down 107 | - API redirect latency degradation 108 | - Request throughput outside configured bounds 109 | - Flusher failure or unexpected idle time 110 | 111 | --- 112 | 113 | ## API Endpoints 114 | 115 | | Method | Endpoint | Access | Description | 116 | |--------|-----------------------------|---------------|------------------------------------------| 117 | | POST | `/api/register` | Public | Register a new user | 118 | | POST | `/api/login` | Public | Obtain JWT access token | 119 | | GET | `/api/me` | Authenticated | Retrieve logged-in user profile | 120 | | POST | `/api/shorten` | Authenticated | Create a new shortened URL | 121 | | GET | `/api/{code}` | Public | Resolve and redirect from short code | 122 | | GET | `/api/my/urls` | Authenticated | List URLs created by the current user | 123 | | GET | `/api/admin/users` | Admin Only | List all registered users | 124 | | DELETE | `/api/admin/users/{id}` | Admin Only | Remove user by ID | 125 | | GET | `/api/docs/index.html` | Protected | View full API documentation | 126 | | GET | `/health` | Public | Get real-time system health report | 127 | 128 | --- 129 | 130 | ## Reverse Proxy & Routing 131 | 132 | All routes are proxied securely via Traefik with rate limiting and middleware protections. 133 | 134 | | Path Prefix | Destination | Access Protection | 135 | |-----------------|--------------------|------------------------------| 136 | | `/api` | url-shortener | JWT-based, rate limited | 137 | | `/grafana` | grafana | IP allowlist + Basic Auth | 138 | | `/prometheus` | prometheus | IP allowlist + Basic Auth | 139 | | `/alert` | alertmanager | IP allowlist + Basic Auth | 140 | | `/metrics` | exporters + app | IP allowlist + Basic Auth | 141 | | `/health` | url-shortener | Public | 142 | 143 | Rate limit settings are configured in `.env` and applied via Traefik middlewares. 144 | 145 | --- 146 | 147 | ## File & Folder Structure 148 | 149 | | Path | Description | 150 | |-----------------------|------------------------------------------------| 151 | | `cmd/` | Application entrypoint | 152 | | `config/` | Configuration loading (env, JWT, SMTP, etc.) | 153 | | `internal/` | Business logic, services, handlers | 154 | | `email/` | Static HTML templates and assets | 155 | | `docs/` | Auto-generated Swagger specs | 156 | | `prometheus/` | Alerting rules and scrape configs | 157 | | `traefik/` | Traefik dynamic config (middlewares, routers) | 158 | | `logs/` | Per-component log output | 159 | | `Makefile` | DevOps helper commands | 160 | | `.env.example` | Sample environment configuration | 161 | | `docker-compose.yml` | Orchestration for all services | 162 | 163 | --- 164 | 165 | ## Deployment Requirements 166 | 167 | - Docker Engine 20.10+ 168 | - Docker Compose v2.20+ 169 | - Public domain name (e.g. `cemakan.com.tr`) 170 | - SMTP credentials (username/password) 171 | - Public access to port 443 for Let’s Encrypt TLS 172 | 173 | --- 174 | 175 | ## Deployment Steps 176 | 177 | ```bash 178 | git clone https://github.com/CemAkan/url-shortener.git 179 | cd url-shortener 180 | 181 | cp .env.example .env 182 | nano .env # configure DB, Redis, JWT_SECRET, SMTP, etc. 183 | 184 | # No need to manually generate alertmanager.yml. It is auto-rendered at container startup. 185 | 186 | docker compose up -d --build 187 | ``` 188 | 189 | 190 | --- 191 | 192 | ## Accessible Routes After Deployment 193 | 194 | | Service | URL | 195 | |--------------------|------------------------------------------| 196 | | API | `https://yourdomain.com/api` | 197 | | Swagger Docs | `https://yourdomain.com/api/docs` | 198 | | Metrics | `https://yourdomain.com/metrics` | 199 | | Grafana Dashboard | `https://yourdomain.com/grafana` | 200 | | Prometheus UI | `https://yourdomain.com/prometheus` | 201 | | Alertmanager | `https://yourdomain.com/alert` | 202 | | Healthcheck | `https://yourdomain.com/health` | 203 | 204 | --- 205 | 206 | ## CI/CD & Automation 207 | 208 | - **GitHub Actions**: 209 | - On every push, Docker images are automatically built, tested, and published. 210 | - **Docker Image**: 211 | - Available at [`cemakan/url-shortener`](https://hub.docker.com/r/cemakan/url-shortener) 212 | - **Makefile Features**: 213 | - Local testing utilities 214 | - Database migration automation 215 | - Service cleanup routines 216 | - Centralized log rotation/archive commands 217 | - **Log Structure**: 218 | - Logs are stored in `/logs/{component}.log`, organized per subsystem. 219 | 220 | --- 221 | 222 | ## 📝 Project Goals 223 | 224 | | Feature | Status | 225 | |-----------------------------------------|--------| 226 | | Full API with JWT and Admin Role | ✅ | 227 | | SMTP Email Verification | ✅ | 228 | | Redis Click Cache | ✅ | 229 | | Prometheus Monitoring | ✅ | 230 | | Grafana Dashboards | ✅ | 231 | | Alertmanager Email Alerts | ✅ | 232 | | Rate Limiting via Traefik Middleware | ✅ | 233 | | IP Whitelist & Basic Auth | ✅ | 234 | | Healthcheck Endpoint & Verifier Job | ✅ | 235 | | CI/CD with GitHub Actions & Docker Hub | ✅ | 236 | | Per-component Log Architecture | ✅ | 237 | | QR Code Support for Short URLs | ⏳ | 238 | | Slack & Telegram-Bot Alert Integrations | ⏳ | 239 | | Self-service API Key Management | ⏳ | 240 | | CSV Export for Admin Panels | ⏳ | 241 | | Multi-tenant Domain Routing Support | ⏳ | 242 | | Role-based Access Control (RBAC) | ⏳ | 243 | | Geo-based Redirect Rules | ⏳ | 244 | | Delayed Cache Flushing Strategy | ⏳ | 245 | 246 | > ✅ Implemented  ⏳ Planned/In Progress 247 | --- 248 | 249 | ## License 250 | 251 | This project is licensed under the **MIT License**. 252 | See the [LICENSE](./LICENSE) file for complete details. 253 | 254 | --- 255 | 256 | ## Maintainer 257 | 258 | **Cem Akan** 259 | Docker Hub: [cemakan/url-shortener](https://hub.docker.com/r/cemakan/url-shortener) 260 | 261 | For contributions, feature requests, or bug reports, please [open an issue](https://github.com/CemAkan/url-shortener/issues) or submit a pull request via GitHub. 262 | 263 | --- -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | // @title URL Shortener API 2 | // @version 1.0 3 | // @description Enterprise grade URL shortening service. 4 | // @host localhost:3000 5 | // @BasePath /api 6 | 7 | package main 8 | 9 | import ( 10 | "context" 11 | "github.com/CemAkan/url-shortener/config" 12 | "github.com/CemAkan/url-shortener/internal/delivery/http/handler" 13 | "github.com/CemAkan/url-shortener/internal/delivery/http/router" 14 | "github.com/CemAkan/url-shortener/internal/health" 15 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 16 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 17 | "github.com/CemAkan/url-shortener/internal/infrastructure/mail" 18 | "github.com/CemAkan/url-shortener/internal/jobs" 19 | "github.com/CemAkan/url-shortener/internal/repository" 20 | "github.com/CemAkan/url-shortener/internal/seed" 21 | "github.com/CemAkan/url-shortener/internal/service" 22 | "github.com/CemAkan/url-shortener/internal/system" 23 | "github.com/CemAkan/url-shortener/pkg/logger" 24 | "github.com/gofiber/fiber/v2" 25 | "strings" 26 | "time" 27 | ) 28 | 29 | func main() { 30 | config.LoadEnv() 31 | mail.InitMail() 32 | logger.InitLogger() 33 | db.InitDB() 34 | seed.SeedAdminUser() 35 | cache.InitRedis() 36 | 37 | appFiber := fiber.New(fiber.Config{ 38 | DisableStartupMessage: true, 39 | StrictRouting: false, 40 | CaseSensitive: true, 41 | ProxyHeader: string(fiber.HeaderXForwardedFor), 42 | ReadTimeout: 5 * time.Second, 43 | WriteTimeout: 10 * time.Second, 44 | TrustedProxies: strings.Split(config.GetEnv("TRUST_PROXY_IPS", "0.0.0.0/0"), ","), 45 | }) 46 | 47 | // Dependency injection 48 | 49 | //MAIL 50 | mailService := service.NewMailService() 51 | 52 | //USER 53 | 54 | //auth 55 | userRepo := repository.NewUserRepository() 56 | userService := service.NewUserService(userRepo) 57 | authHandler := handler.NewAuthHandler(userService, mailService) 58 | 59 | //verification 60 | verificationHandler := handler.NewVerificationHandler(userService) 61 | 62 | //URL 63 | urlRepo := repository.NewURLRepository() 64 | urlService := service.NewURLService(urlRepo) 65 | urlHandler := handler.NewURLHandler(urlService) 66 | 67 | //ADMIN 68 | adminHandler := handler.NewAdminHandler(userService, urlService) 69 | 70 | //send handlers 71 | router.SetupRoutes(appFiber, authHandler, urlHandler, adminHandler, verificationHandler) 72 | 73 | //jobs 74 | clickFlusher := service.NewClickFlusherService(urlRepo) 75 | go job.StartClickFlushJob(clickFlusher, 1*time.Minute) 76 | 77 | ctx, cancel := context.WithCancel(context.Background()) 78 | defer cancel() 79 | 80 | //single handler start 81 | go system.HandleSignals(cancel) 82 | 83 | //health watchdog start 84 | go health.StartWatchdog(ctx) 85 | 86 | //server start 87 | go startServer(appFiber, cancel) 88 | 89 | <-ctx.Done() //chan-block wait 90 | 91 | //graceful shutdown start 92 | system.GracefulShutdown(appFiber) 93 | } 94 | 95 | func startServer(app *fiber.App, cancel context.CancelFunc) { 96 | port := config.GetEnv("APP_PORT", "3000") 97 | logger.Log.Infof("Starting Fiber on port: %s", port) 98 | 99 | err := app.Listen(":" + port) 100 | 101 | if err != nil { 102 | logger.Log.WithError(err).Error("Fiber server failed to start") 103 | cancel() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "log" 6 | "os" 7 | ) 8 | 9 | func LoadEnv() { 10 | err := godotenv.Load() 11 | 12 | if err != nil { 13 | log.Println("No local .env file found, continue with system env vars.") 14 | } 15 | } 16 | 17 | // GetEnv gets env value with key parameter and returning fallback value if env record is not exist 18 | func GetEnv(key, fallback string) string { 19 | value, isExist := os.LookupEnv(key) 20 | 21 | if !isExist { 22 | return fallback 23 | } 24 | 25 | return value 26 | 27 | } 28 | -------------------------------------------------------------------------------- /config/jwt.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/golang-jwt/jwt/v5" 5 | "os" 6 | "time" 7 | ) 8 | 9 | var jwtSecret = []byte(os.Getenv("JWT_SECRET")) 10 | 11 | func GenerateToken(userID uint, expiresIn time.Duration, purpose string) (string, error) { 12 | claims := jwt.MapClaims{ 13 | "user_id": userID, 14 | "exp": time.Now().Add(expiresIn).Unix(), 15 | "type": purpose, 16 | } 17 | 18 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 19 | return token.SignedString(jwtSecret) 20 | } 21 | 22 | func ResolveToken(tokenStr string) (*jwt.Token, error) { 23 | token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { 24 | return jwtSecret, nil 25 | }) 26 | 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return token, nil 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: cemakan/url-shortener:latest 4 | container_name: url-shortener 5 | env_file: [.env] 6 | volumes: 7 | - ./.env:/.env:ro 8 | labels: 9 | - "traefik.enable=true" 10 | - "traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)" 11 | - "traefik.http.routers.api.entrypoints=websecure" 12 | - "traefik.http.routers.api.tls.certresolver=letsencrypt" 13 | - "traefik.http.routers.api.priority=10" 14 | #rate limit middleware 15 | - "traefik.http.routers.api.middlewares=api-ratelimit" 16 | - "traefik.http.middlewares.api-ratelimit.ratelimit.average=${RATE_LIMIT_AVERAGE}" 17 | - "traefik.http.middlewares.api-ratelimit.ratelimit.burst=${RATE_LIMIT_BURST}" 18 | - "traefik.http.middlewares.api-ratelimit.ratelimit.period=${RATE_LIMIT_PERIOD}" 19 | - "traefik.http.services.api.loadbalancer.server.port=${APP_PORT}" 20 | expose: 21 | - "${APP_PORT}" 22 | depends_on: 23 | postgres: 24 | condition: service_healthy 25 | redis: 26 | condition: service_healthy 27 | networks: 28 | - internal 29 | - proxy 30 | 31 | grafana: 32 | image: grafana/grafana:latest 33 | container_name: grafana 34 | env_file: [.env] 35 | environment: 36 | - GF_SERVER_ROOT_URL=/grafana 37 | - GF_SERVER_SERVE_FROM_SUB_PATH=true 38 | expose: 39 | - "3000" 40 | volumes: 41 | - grafana_data:/var/lib/grafana 42 | depends_on: [prometheus] 43 | labels: 44 | - "traefik.enable=true" 45 | - "traefik.http.routers.grafana.rule=Host(`${DOMAIN}`) && PathPrefix(`/grafana`)" 46 | - "traefik.http.routers.grafana.entrypoints=websecure" 47 | - "traefik.http.routers.grafana.tls.certresolver=letsencrypt" 48 | - "traefik.http.routers.grafana.priority=100" 49 | - "traefik.http.routers.grafana.middlewares=grafana-auth,grafana-ip" 50 | - "traefik.http.middlewares.grafana-auth.basicauth.users=${BASIC_AUTH_USERS}" 51 | - "traefik.http.middlewares.grafana-ip.ipallowlist.sourcerange=${IP_WHITELIST}" 52 | - "traefik.http.services.grafana.loadbalancer.server.port=3000" 53 | networks: 54 | - internal 55 | - proxy 56 | 57 | prometheus: 58 | image: prom/prometheus:latest 59 | container_name: prometheus 60 | env_file: [.env] 61 | expose: 62 | - "9090" 63 | volumes: 64 | - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro 65 | - ./prometheus/rules.yml:/etc/prometheus/rules.yml:ro 66 | - prometheus_data:/prometheus 67 | command: 68 | - "--config.file=/etc/prometheus/prometheus.yml" 69 | - "--web.route-prefix=/prometheus" 70 | - "--web.external-url=https://${DOMAIN}/prometheus" 71 | - "--web.enable-lifecycle" 72 | labels: 73 | - "traefik.enable=true" 74 | - "traefik.http.routers.prom.rule=Host(`${DOMAIN}`) && PathPrefix(`/prometheus`)" 75 | - "traefik.http.routers.prom.entrypoints=websecure" 76 | - "traefik.http.routers.prom.tls.certresolver=letsencrypt" 77 | - "traefik.http.routers.prom.priority=90" 78 | - "traefik.http.routers.prom.middlewares=prom-auth,prom-ip" 79 | - "traefik.http.middlewares.prom-auth.basicauth.users=${BASIC_AUTH_USERS}" 80 | - "traefik.http.middlewares.prom-ip.ipallowlist.sourcerange=${IP_WHITELIST}" 81 | - "traefik.http.services.prom.loadbalancer.server.port=9090" 82 | restart: unless-stopped 83 | networks: 84 | - internal 85 | - proxy 86 | 87 | config-renderer: 88 | image: alpine:3.18 89 | env_file: 90 | - .env 91 | entrypoint: 92 | - sh 93 | - -c 94 | - | 95 | apk add --no-cache gettext \ 96 | && envsubst < /templates/alertmanager.tmpl.yml > /data/alertmanager.yml 97 | volumes: 98 | - ./prometheus/alertmanager/alertmanager.tmpl.yml:/templates/alertmanager.tmpl.yml:ro 99 | - ./prometheus/alertmanager:/data 100 | restart: 'no' 101 | 102 | alertmanager: 103 | image: prom/alertmanager:latest 104 | container_name: alertmanager 105 | restart: on-failure 106 | env_file: [.env] 107 | expose: 108 | - "9093" 109 | depends_on: 110 | - config-renderer 111 | volumes: 112 | - ./prometheus/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro 113 | - alertmanager_data:/alertmanager 114 | command: 115 | - "--config.file=/etc/alertmanager/alertmanager.yml" 116 | - "--web.route-prefix=/alert" 117 | - "--web.external-url=https://${DOMAIN}/alert" 118 | - "--storage.path=/alertmanager" 119 | labels: 120 | - "traefik.enable=true" 121 | - "traefik.http.routers.alert.rule=Host(`${DOMAIN}`) && PathPrefix(`/alert`)" 122 | - "traefik.http.routers.alert.entrypoints=websecure" 123 | - "traefik.http.routers.alert.tls.certresolver=letsencrypt" 124 | - "traefik.http.routers.alert.priority=80" 125 | - "traefik.http.routers.alert.middlewares=alert-auth,alert-ip" 126 | - "traefik.http.middlewares.alert-auth.basicauth.users=${BASIC_AUTH_USERS}" 127 | - "traefik.http.middlewares.alert-ip.ipallowlist.sourcerange=${IP_WHITELIST}" 128 | - "traefik.http.services.alert.loadbalancer.server.port=9093" 129 | networks: 130 | - internal 131 | - proxy 132 | 133 | postgres: 134 | image: postgres:16-alpine 135 | container_name: pg-shortener 136 | env_file: [.env] 137 | healthcheck: 138 | test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] 139 | interval: 5s 140 | retries: 10 141 | expose: 142 | - "5432" 143 | volumes: 144 | - pgdata:/var/lib/postgresql/data 145 | networks: 146 | - internal 147 | 148 | redis: 149 | image: redis:7-alpine 150 | container_name: redis-shortener 151 | env_file: [.env] 152 | healthcheck: 153 | test: ["CMD", "redis-cli", "ping"] 154 | interval: 5s 155 | retries: 10 156 | expose: 157 | - "6379" 158 | volumes: 159 | - redisdata:/data 160 | networks: 161 | - internal 162 | 163 | postgres_exporter: 164 | image: prometheuscommunity/postgres-exporter:latest 165 | container_name: postgres_exporter 166 | env_file: [.env] 167 | depends_on: 168 | postgres: 169 | condition: service_healthy 170 | expose: 171 | - "9187" 172 | environment: 173 | - DATA_SOURCE_NAME=${DATA_SOURCE_NAME} 174 | - PG_EXPORTER_DISABLE_SETTINGS_METRICS=true 175 | networks: 176 | - internal 177 | 178 | node_exporter: 179 | image: prom/node-exporter:latest 180 | container_name: node_exporter 181 | restart: unless-stopped 182 | expose: 183 | - "9100" 184 | networks: 185 | - internal 186 | 187 | networks: 188 | internal: 189 | proxy: 190 | external: true 191 | 192 | volumes: 193 | pgdata: 194 | redisdata: 195 | prometheus_data: 196 | grafana_data: 197 | alertmanager_data: -------------------------------------------------------------------------------- /docs/docs.go: -------------------------------------------------------------------------------- 1 | // Package docs Code generated by swaggo/swag. DO NOT EDIT 2 | package docs 3 | 4 | import "github.com/swaggo/swag" 5 | 6 | const docTemplate = `{ 7 | "schemes": {{ marshal .Schemes }}, 8 | "swagger": "2.0", 9 | "info": { 10 | "description": "{{escape .Description}}", 11 | "title": "{{.Title}}", 12 | "contact": {}, 13 | "version": "{{.Version}}" 14 | }, 15 | "host": "{{.Host}}", 16 | "basePath": "{{.BasePath}}", 17 | "paths": { 18 | "/admin/users": { 19 | "get": { 20 | "security": [ 21 | { 22 | "BearerAuth": [] 23 | } 24 | ], 25 | "description": "Retrieves all users with their associated short URLs", 26 | "produces": [ 27 | "application/json" 28 | ], 29 | "tags": [ 30 | "Admin" 31 | ], 32 | "summary": "List all users and their URLs", 33 | "responses": { 34 | "200": { 35 | "description": "OK", 36 | "schema": { 37 | "type": "array", 38 | "items": { 39 | "$ref": "#/definitions/response.UserURLsResponse" 40 | } 41 | } 42 | }, 43 | "500": { 44 | "description": "Internal Server Error", 45 | "schema": { 46 | "$ref": "#/definitions/response.ErrorResponse" 47 | } 48 | } 49 | } 50 | } 51 | }, 52 | "/admin/users/{id}": { 53 | "delete": { 54 | "security": [ 55 | { 56 | "BearerAuth": [] 57 | } 58 | ], 59 | "description": "Deletes a user by ID and all associated short URLs \u0026 Redis entries", 60 | "produces": [ 61 | "application/json" 62 | ], 63 | "tags": [ 64 | "Admin" 65 | ], 66 | "summary": "Delete a user and all related URLs", 67 | "parameters": [ 68 | { 69 | "type": "integer", 70 | "description": "User ID", 71 | "name": "id", 72 | "in": "path", 73 | "required": true 74 | } 75 | ], 76 | "responses": { 77 | "200": { 78 | "description": "OK", 79 | "schema": { 80 | "$ref": "#/definitions/response.SuccessResponse" 81 | } 82 | }, 83 | "400": { 84 | "description": "Bad Request", 85 | "schema": { 86 | "$ref": "#/definitions/response.ErrorResponse" 87 | } 88 | }, 89 | "500": { 90 | "description": "Internal Server Error", 91 | "schema": { 92 | "$ref": "#/definitions/response.ErrorResponse" 93 | } 94 | } 95 | } 96 | } 97 | }, 98 | "/health": { 99 | "get": { 100 | "description": "Returns current health status of DB, Redis and Email services", 101 | "tags": [ 102 | "Health" 103 | ], 104 | "summary": "Health Check", 105 | "responses": { 106 | "200": { 107 | "description": "OK", 108 | "schema": { 109 | "$ref": "#/definitions/response.HealthStatusResponse" 110 | } 111 | } 112 | } 113 | } 114 | }, 115 | "/login": { 116 | "post": { 117 | "description": "Authenticates a user and returns JWT token", 118 | "consumes": [ 119 | "application/json" 120 | ], 121 | "produces": [ 122 | "application/json" 123 | ], 124 | "tags": [ 125 | "Auth" 126 | ], 127 | "summary": "User Login", 128 | "parameters": [ 129 | { 130 | "description": "User Credentials", 131 | "name": "request", 132 | "in": "body", 133 | "required": true, 134 | "schema": { 135 | "$ref": "#/definitions/request.AuthRequest" 136 | } 137 | } 138 | ], 139 | "responses": { 140 | "201": { 141 | "description": "Created", 142 | "schema": { 143 | "$ref": "#/definitions/response.LoginResponse" 144 | } 145 | }, 146 | "400": { 147 | "description": "Bad Request", 148 | "schema": { 149 | "$ref": "#/definitions/response.ErrorResponse" 150 | } 151 | } 152 | } 153 | } 154 | }, 155 | "/me": { 156 | "get": { 157 | "security": [ 158 | { 159 | "BearerAuth": [] 160 | } 161 | ], 162 | "description": "Returns authenticated user's profile info", 163 | "produces": [ 164 | "application/json" 165 | ], 166 | "tags": [ 167 | "Auth" 168 | ], 169 | "summary": "Get current user's profile", 170 | "responses": { 171 | "200": { 172 | "description": "OK", 173 | "schema": { 174 | "$ref": "#/definitions/response.UserResponse" 175 | } 176 | }, 177 | "500": { 178 | "description": "Internal Server Error", 179 | "schema": { 180 | "$ref": "#/definitions/response.ErrorResponse" 181 | } 182 | } 183 | } 184 | } 185 | }, 186 | "/my/urls": { 187 | "get": { 188 | "security": [ 189 | { 190 | "BearerAuth": [] 191 | } 192 | ], 193 | "description": "Retrieves all shortened URLs for authenticated user", 194 | "produces": [ 195 | "application/json" 196 | ], 197 | "tags": [ 198 | "URL" 199 | ], 200 | "summary": "Get user's URLs", 201 | "responses": { 202 | "200": { 203 | "description": "OK", 204 | "schema": { 205 | "type": "array", 206 | "items": { 207 | "$ref": "#/definitions/response.URLResponse" 208 | } 209 | } 210 | }, 211 | "500": { 212 | "description": "Internal Server Error", 213 | "schema": { 214 | "$ref": "#/definitions/response.ErrorResponse" 215 | } 216 | } 217 | } 218 | } 219 | }, 220 | "/my/urls/{code}": { 221 | "get": { 222 | "security": [ 223 | { 224 | "BearerAuth": [] 225 | } 226 | ], 227 | "description": "Retrieves a single short URL details with daily click count", 228 | "produces": [ 229 | "application/json" 230 | ], 231 | "tags": [ 232 | "URL" 233 | ], 234 | "summary": "Get a single URL detail", 235 | "parameters": [ 236 | { 237 | "type": "string", 238 | "description": "Short URL code", 239 | "name": "code", 240 | "in": "path", 241 | "required": true 242 | } 243 | ], 244 | "responses": { 245 | "200": { 246 | "description": "OK", 247 | "schema": { 248 | "$ref": "#/definitions/response.DetailedURLResponse" 249 | } 250 | }, 251 | "404": { 252 | "description": "Not Found", 253 | "schema": { 254 | "$ref": "#/definitions/response.ErrorResponse" 255 | } 256 | } 257 | } 258 | }, 259 | "delete": { 260 | "security": [ 261 | { 262 | "BearerAuth": [] 263 | } 264 | ], 265 | "description": "Deletes a user's shortened URL by code", 266 | "produces": [ 267 | "application/json" 268 | ], 269 | "tags": [ 270 | "URL" 271 | ], 272 | "summary": "Delete a shortened URL", 273 | "parameters": [ 274 | { 275 | "type": "string", 276 | "description": "Short URL code", 277 | "name": "code", 278 | "in": "path", 279 | "required": true 280 | } 281 | ], 282 | "responses": { 283 | "200": { 284 | "description": "OK", 285 | "schema": { 286 | "$ref": "#/definitions/response.SuccessResponse" 287 | } 288 | }, 289 | "403": { 290 | "description": "Forbidden", 291 | "schema": { 292 | "$ref": "#/definitions/response.ErrorResponse" 293 | } 294 | } 295 | } 296 | }, 297 | "patch": { 298 | "security": [ 299 | { 300 | "BearerAuth": [] 301 | } 302 | ], 303 | "description": "Updates original URL or custom code for a user's URL", 304 | "consumes": [ 305 | "application/json" 306 | ], 307 | "produces": [ 308 | "application/json" 309 | ], 310 | "tags": [ 311 | "URL" 312 | ], 313 | "summary": "Update a shortened URL", 314 | "parameters": [ 315 | { 316 | "type": "string", 317 | "description": "Short URL code", 318 | "name": "code", 319 | "in": "path", 320 | "required": true 321 | }, 322 | { 323 | "description": "Updated URL info", 324 | "name": "request", 325 | "in": "body", 326 | "required": true, 327 | "schema": { 328 | "$ref": "#/definitions/request.UpdateURLRequest" 329 | } 330 | } 331 | ], 332 | "responses": { 333 | "200": { 334 | "description": "OK", 335 | "schema": { 336 | "$ref": "#/definitions/response.SuccessResponse" 337 | } 338 | }, 339 | "400": { 340 | "description": "Bad Request", 341 | "schema": { 342 | "$ref": "#/definitions/response.ErrorResponse" 343 | } 344 | }, 345 | "403": { 346 | "description": "Forbidden", 347 | "schema": { 348 | "$ref": "#/definitions/response.ErrorResponse" 349 | } 350 | } 351 | } 352 | } 353 | }, 354 | "/password/reset": { 355 | "get": { 356 | "security": [ 357 | { 358 | "BearerAuth": [] 359 | } 360 | ], 361 | "description": "Sends password reset link to user's email", 362 | "produces": [ 363 | "application/json" 364 | ], 365 | "tags": [ 366 | "Auth" 367 | ], 368 | "summary": "Send password reset mail", 369 | "responses": { 370 | "200": { 371 | "description": "OK", 372 | "schema": { 373 | "$ref": "#/definitions/response.SuccessResponse" 374 | } 375 | }, 376 | "404": { 377 | "description": "Not Found", 378 | "schema": { 379 | "$ref": "#/definitions/response.ErrorResponse" 380 | } 381 | }, 382 | "500": { 383 | "description": "Internal Server Error", 384 | "schema": { 385 | "$ref": "#/definitions/response.ErrorResponse" 386 | } 387 | } 388 | } 389 | } 390 | }, 391 | "/register": { 392 | "post": { 393 | "description": "Creates a new user account", 394 | "consumes": [ 395 | "application/json" 396 | ], 397 | "produces": [ 398 | "application/json" 399 | ], 400 | "tags": [ 401 | "Auth" 402 | ], 403 | "summary": "User Registration", 404 | "parameters": [ 405 | { 406 | "description": "User Credentials", 407 | "name": "request", 408 | "in": "body", 409 | "required": true, 410 | "schema": { 411 | "$ref": "#/definitions/request.AuthRequest" 412 | } 413 | } 414 | ], 415 | "responses": { 416 | "201": { 417 | "description": "Created", 418 | "schema": { 419 | "$ref": "#/definitions/response.UserResponse" 420 | } 421 | }, 422 | "400": { 423 | "description": "Bad Request", 424 | "schema": { 425 | "$ref": "#/definitions/response.ErrorResponse" 426 | } 427 | } 428 | } 429 | } 430 | }, 431 | "/shorten": { 432 | "post": { 433 | "security": [ 434 | { 435 | "BearerAuth": [] 436 | } 437 | ], 438 | "description": "Create a shortened URL with optional custom code", 439 | "consumes": [ 440 | "application/json" 441 | ], 442 | "produces": [ 443 | "application/json" 444 | ], 445 | "tags": [ 446 | "URL" 447 | ], 448 | "summary": "Shorten a URL", 449 | "parameters": [ 450 | { 451 | "description": "URL to shorten", 452 | "name": "request", 453 | "in": "body", 454 | "required": true, 455 | "schema": { 456 | "$ref": "#/definitions/request.ShortenURLRequest" 457 | } 458 | } 459 | ], 460 | "responses": { 461 | "201": { 462 | "description": "Created", 463 | "schema": { 464 | "$ref": "#/definitions/response.URLResponse" 465 | } 466 | }, 467 | "400": { 468 | "description": "Bad Request", 469 | "schema": { 470 | "$ref": "#/definitions/response.ErrorResponse" 471 | } 472 | } 473 | } 474 | } 475 | }, 476 | "/verify/mail/{token}": { 477 | "get": { 478 | "description": "Validates email address through verification token", 479 | "produces": [ 480 | "application/json" 481 | ], 482 | "tags": [ 483 | "Verification" 484 | ], 485 | "summary": "Verify user's email address", 486 | "parameters": [ 487 | { 488 | "type": "string", 489 | "description": "Verification Token", 490 | "name": "token", 491 | "in": "path", 492 | "required": true 493 | } 494 | ], 495 | "responses": { 496 | "200": { 497 | "description": "OK", 498 | "schema": { 499 | "$ref": "#/definitions/response.SuccessResponse" 500 | } 501 | }, 502 | "404": { 503 | "description": "Not Found", 504 | "schema": { 505 | "$ref": "#/definitions/response.ErrorResponse" 506 | } 507 | }, 508 | "500": { 509 | "description": "Internal Server Error", 510 | "schema": { 511 | "$ref": "#/definitions/response.ErrorResponse" 512 | } 513 | } 514 | } 515 | } 516 | }, 517 | "/verify/password": { 518 | "post": { 519 | "security": [ 520 | { 521 | "BearerAuth": [] 522 | } 523 | ], 524 | "description": "Sets new password after token verification", 525 | "consumes": [ 526 | "application/json" 527 | ], 528 | "produces": [ 529 | "application/json" 530 | ], 531 | "tags": [ 532 | "Verification" 533 | ], 534 | "summary": "Reset user password with verification token", 535 | "parameters": [ 536 | { 537 | "description": "New Password", 538 | "name": "request", 539 | "in": "body", 540 | "required": true, 541 | "schema": { 542 | "$ref": "#/definitions/request.NewPassword" 543 | } 544 | } 545 | ], 546 | "responses": { 547 | "200": { 548 | "description": "OK", 549 | "schema": { 550 | "$ref": "#/definitions/response.SuccessResponse" 551 | } 552 | }, 553 | "400": { 554 | "description": "Bad Request", 555 | "schema": { 556 | "$ref": "#/definitions/response.ErrorResponse" 557 | } 558 | }, 559 | "404": { 560 | "description": "Not Found", 561 | "schema": { 562 | "$ref": "#/definitions/response.ErrorResponse" 563 | } 564 | }, 565 | "500": { 566 | "description": "Internal Server Error", 567 | "schema": { 568 | "$ref": "#/definitions/response.ErrorResponse" 569 | } 570 | } 571 | } 572 | } 573 | }, 574 | "/verify/password/{token}": { 575 | "get": { 576 | "description": "Sets new password after token verification", 577 | "consumes": [ 578 | "application/json" 579 | ], 580 | "produces": [ 581 | "application/json" 582 | ], 583 | "tags": [ 584 | "Verification" 585 | ], 586 | "summary": "Return verification token to use reset user password", 587 | "parameters": [ 588 | { 589 | "type": "string", 590 | "description": "Verification Token", 591 | "name": "token", 592 | "in": "path", 593 | "required": true 594 | } 595 | ], 596 | "responses": { 597 | "200": { 598 | "description": "OK", 599 | "schema": { 600 | "$ref": "#/definitions/response.SuccessResponse" 601 | } 602 | } 603 | } 604 | } 605 | }, 606 | "/{code}": { 607 | "get": { 608 | "description": "Redirects to original URL based on short code", 609 | "tags": [ 610 | "URL" 611 | ], 612 | "summary": "Redirect short URL to original URL", 613 | "parameters": [ 614 | { 615 | "type": "string", 616 | "description": "Short URL code", 617 | "name": "code", 618 | "in": "path", 619 | "required": true 620 | } 621 | ], 622 | "responses": { 623 | "302": { 624 | "description": "Redirects to original URL", 625 | "schema": { 626 | "type": "string" 627 | } 628 | }, 629 | "404": { 630 | "description": "Not Found", 631 | "schema": { 632 | "$ref": "#/definitions/response.ErrorResponse" 633 | } 634 | } 635 | } 636 | } 637 | } 638 | }, 639 | "definitions": { 640 | "entity.URL": { 641 | "type": "object", 642 | "properties": { 643 | "code": { 644 | "type": "string" 645 | }, 646 | "original_url": { 647 | "type": "string" 648 | }, 649 | "total_clicks": { 650 | "type": "integer" 651 | }, 652 | "user_id": { 653 | "type": "integer" 654 | } 655 | } 656 | }, 657 | "entity.User": { 658 | "type": "object", 659 | "properties": { 660 | "email": { 661 | "type": "string" 662 | }, 663 | "is_admin": { 664 | "type": "boolean" 665 | }, 666 | "is_verified": { 667 | "type": "boolean" 668 | }, 669 | "name": { 670 | "type": "string" 671 | }, 672 | "surname": { 673 | "type": "string" 674 | } 675 | } 676 | }, 677 | "request.AuthRequest": { 678 | "type": "object", 679 | "required": [ 680 | "email", 681 | "password" 682 | ], 683 | "properties": { 684 | "email": { 685 | "type": "string", 686 | "example": "asko@kusko.com" 687 | }, 688 | "name": { 689 | "type": "string", 690 | "example": "Cem" 691 | }, 692 | "password": { 693 | "type": "string", 694 | "minLength": 8, 695 | "example": "supersecret" 696 | }, 697 | "surname": { 698 | "type": "string", 699 | "example": "Akan" 700 | } 701 | } 702 | }, 703 | "request.NewPassword": { 704 | "type": "object", 705 | "properties": { 706 | "password": { 707 | "type": "string", 708 | "example": "newsecurepassword" 709 | } 710 | } 711 | }, 712 | "request.ShortenURLRequest": { 713 | "type": "object", 714 | "properties": { 715 | "custom_code": { 716 | "type": "string", 717 | "example": "custom123" 718 | }, 719 | "original_url": { 720 | "type": "string", 721 | "example": "https://google.com" 722 | } 723 | } 724 | }, 725 | "request.UpdateURLRequest": { 726 | "type": "object", 727 | "properties": { 728 | "new_custom_code": { 729 | "type": "string", 730 | "example": "newcode123" 731 | }, 732 | "new_original_url": { 733 | "type": "string", 734 | "example": "https://updated.com" 735 | } 736 | } 737 | }, 738 | "response.DetailedURLResponse": { 739 | "type": "object", 740 | "properties": { 741 | "code": { 742 | "type": "string", 743 | "example": "abc123" 744 | }, 745 | "daily_clicks": { 746 | "type": "integer", 747 | "example": 10 748 | }, 749 | "original_url": { 750 | "type": "string", 751 | "example": "https://google.com" 752 | }, 753 | "total_clicks": { 754 | "type": "integer", 755 | "example": 42 756 | } 757 | } 758 | }, 759 | "response.ErrorResponse": { 760 | "type": "object", 761 | "properties": { 762 | "error": { 763 | "type": "string", 764 | "example": "invalid credentials" 765 | } 766 | } 767 | }, 768 | "response.HealthStatusResponse": { 769 | "type": "object", 770 | "properties": { 771 | "database": { 772 | "allOf": [ 773 | { 774 | "$ref": "#/definitions/response.Status" 775 | } 776 | ], 777 | "example": "healthy" 778 | }, 779 | "email": { 780 | "allOf": [ 781 | { 782 | "$ref": "#/definitions/response.Status" 783 | } 784 | ], 785 | "example": "healthy" 786 | }, 787 | "redis": { 788 | "allOf": [ 789 | { 790 | "$ref": "#/definitions/response.Status" 791 | } 792 | ], 793 | "example": "healthy" 794 | }, 795 | "status": { 796 | "allOf": [ 797 | { 798 | "$ref": "#/definitions/response.Status" 799 | } 800 | ], 801 | "example": "healthy" 802 | } 803 | } 804 | }, 805 | "response.LoginResponse": { 806 | "type": "object", 807 | "properties": { 808 | "email": { 809 | "type": "string", 810 | "example": "asko@kusko.com" 811 | }, 812 | "id": { 813 | "type": "integer", 814 | "example": 1 815 | }, 816 | "token": { 817 | "type": "string", 818 | "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." 819 | } 820 | } 821 | }, 822 | "response.Status": { 823 | "type": "string", 824 | "enum": [ 825 | "healthy", 826 | "degraded", 827 | "unhealthy" 828 | ], 829 | "x-enum-varnames": [ 830 | "StatusHealthy", 831 | "StatusDegraded", 832 | "StatusUnhealthy" 833 | ] 834 | }, 835 | "response.SuccessResponse": { 836 | "type": "object", 837 | "properties": { 838 | "success": { 839 | "type": "string", 840 | "example": "Operation successful" 841 | } 842 | } 843 | }, 844 | "response.URLResponse": { 845 | "type": "object", 846 | "properties": { 847 | "code": { 848 | "type": "string", 849 | "example": "abc123" 850 | }, 851 | "original_url": { 852 | "type": "string", 853 | "example": "https://google.com" 854 | }, 855 | "short_url": { 856 | "type": "string", 857 | "example": "https://localhost/abc123" 858 | } 859 | } 860 | }, 861 | "response.UserResponse": { 862 | "type": "object", 863 | "properties": { 864 | "email": { 865 | "type": "string", 866 | "example": "asko@kusko.com" 867 | }, 868 | "id": { 869 | "type": "integer", 870 | "example": 1 871 | } 872 | } 873 | }, 874 | "response.UserURLsResponse": { 875 | "type": "object", 876 | "properties": { 877 | "urls": { 878 | "type": "array", 879 | "items": { 880 | "$ref": "#/definitions/entity.URL" 881 | } 882 | }, 883 | "user": { 884 | "$ref": "#/definitions/entity.User" 885 | } 886 | } 887 | } 888 | } 889 | }` 890 | 891 | // SwaggerInfo holds exported Swagger Info so clients can modify it 892 | var SwaggerInfo = &swag.Spec{ 893 | Version: "1.0", 894 | Host: "localhost:3000", 895 | BasePath: "/api", 896 | Schemes: []string{}, 897 | Title: "URL Shortener API", 898 | Description: "Enterprise grade URL shortening service.", 899 | InfoInstanceName: "swagger", 900 | SwaggerTemplate: docTemplate, 901 | LeftDelim: "{{", 902 | RightDelim: "}}", 903 | } 904 | 905 | func init() { 906 | swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) 907 | } 908 | -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Enterprise grade URL shortening service.", 5 | "title": "URL Shortener API", 6 | "contact": {}, 7 | "version": "1.0" 8 | }, 9 | "host": "localhost:3000", 10 | "basePath": "/api", 11 | "paths": { 12 | "/admin/users": { 13 | "get": { 14 | "security": [ 15 | { 16 | "BearerAuth": [] 17 | } 18 | ], 19 | "description": "Retrieves all users with their associated short URLs", 20 | "produces": [ 21 | "application/json" 22 | ], 23 | "tags": [ 24 | "Admin" 25 | ], 26 | "summary": "List all users and their URLs", 27 | "responses": { 28 | "200": { 29 | "description": "OK", 30 | "schema": { 31 | "type": "array", 32 | "items": { 33 | "$ref": "#/definitions/response.UserURLsResponse" 34 | } 35 | } 36 | }, 37 | "500": { 38 | "description": "Internal Server Error", 39 | "schema": { 40 | "$ref": "#/definitions/response.ErrorResponse" 41 | } 42 | } 43 | } 44 | } 45 | }, 46 | "/admin/users/{id}": { 47 | "delete": { 48 | "security": [ 49 | { 50 | "BearerAuth": [] 51 | } 52 | ], 53 | "description": "Deletes a user by ID and all associated short URLs \u0026 Redis entries", 54 | "produces": [ 55 | "application/json" 56 | ], 57 | "tags": [ 58 | "Admin" 59 | ], 60 | "summary": "Delete a user and all related URLs", 61 | "parameters": [ 62 | { 63 | "type": "integer", 64 | "description": "User ID", 65 | "name": "id", 66 | "in": "path", 67 | "required": true 68 | } 69 | ], 70 | "responses": { 71 | "200": { 72 | "description": "OK", 73 | "schema": { 74 | "$ref": "#/definitions/response.SuccessResponse" 75 | } 76 | }, 77 | "400": { 78 | "description": "Bad Request", 79 | "schema": { 80 | "$ref": "#/definitions/response.ErrorResponse" 81 | } 82 | }, 83 | "500": { 84 | "description": "Internal Server Error", 85 | "schema": { 86 | "$ref": "#/definitions/response.ErrorResponse" 87 | } 88 | } 89 | } 90 | } 91 | }, 92 | "/health": { 93 | "get": { 94 | "description": "Returns current health status of DB, Redis and Email services", 95 | "tags": [ 96 | "Health" 97 | ], 98 | "summary": "Health Check", 99 | "responses": { 100 | "200": { 101 | "description": "OK", 102 | "schema": { 103 | "$ref": "#/definitions/response.HealthStatusResponse" 104 | } 105 | } 106 | } 107 | } 108 | }, 109 | "/login": { 110 | "post": { 111 | "description": "Authenticates a user and returns JWT token", 112 | "consumes": [ 113 | "application/json" 114 | ], 115 | "produces": [ 116 | "application/json" 117 | ], 118 | "tags": [ 119 | "Auth" 120 | ], 121 | "summary": "User Login", 122 | "parameters": [ 123 | { 124 | "description": "User Credentials", 125 | "name": "request", 126 | "in": "body", 127 | "required": true, 128 | "schema": { 129 | "$ref": "#/definitions/request.AuthRequest" 130 | } 131 | } 132 | ], 133 | "responses": { 134 | "201": { 135 | "description": "Created", 136 | "schema": { 137 | "$ref": "#/definitions/response.LoginResponse" 138 | } 139 | }, 140 | "400": { 141 | "description": "Bad Request", 142 | "schema": { 143 | "$ref": "#/definitions/response.ErrorResponse" 144 | } 145 | } 146 | } 147 | } 148 | }, 149 | "/me": { 150 | "get": { 151 | "security": [ 152 | { 153 | "BearerAuth": [] 154 | } 155 | ], 156 | "description": "Returns authenticated user's profile info", 157 | "produces": [ 158 | "application/json" 159 | ], 160 | "tags": [ 161 | "Auth" 162 | ], 163 | "summary": "Get current user's profile", 164 | "responses": { 165 | "200": { 166 | "description": "OK", 167 | "schema": { 168 | "$ref": "#/definitions/response.UserResponse" 169 | } 170 | }, 171 | "500": { 172 | "description": "Internal Server Error", 173 | "schema": { 174 | "$ref": "#/definitions/response.ErrorResponse" 175 | } 176 | } 177 | } 178 | } 179 | }, 180 | "/my/urls": { 181 | "get": { 182 | "security": [ 183 | { 184 | "BearerAuth": [] 185 | } 186 | ], 187 | "description": "Retrieves all shortened URLs for authenticated user", 188 | "produces": [ 189 | "application/json" 190 | ], 191 | "tags": [ 192 | "URL" 193 | ], 194 | "summary": "Get user's URLs", 195 | "responses": { 196 | "200": { 197 | "description": "OK", 198 | "schema": { 199 | "type": "array", 200 | "items": { 201 | "$ref": "#/definitions/response.URLResponse" 202 | } 203 | } 204 | }, 205 | "500": { 206 | "description": "Internal Server Error", 207 | "schema": { 208 | "$ref": "#/definitions/response.ErrorResponse" 209 | } 210 | } 211 | } 212 | } 213 | }, 214 | "/my/urls/{code}": { 215 | "get": { 216 | "security": [ 217 | { 218 | "BearerAuth": [] 219 | } 220 | ], 221 | "description": "Retrieves a single short URL details with daily click count", 222 | "produces": [ 223 | "application/json" 224 | ], 225 | "tags": [ 226 | "URL" 227 | ], 228 | "summary": "Get a single URL detail", 229 | "parameters": [ 230 | { 231 | "type": "string", 232 | "description": "Short URL code", 233 | "name": "code", 234 | "in": "path", 235 | "required": true 236 | } 237 | ], 238 | "responses": { 239 | "200": { 240 | "description": "OK", 241 | "schema": { 242 | "$ref": "#/definitions/response.DetailedURLResponse" 243 | } 244 | }, 245 | "404": { 246 | "description": "Not Found", 247 | "schema": { 248 | "$ref": "#/definitions/response.ErrorResponse" 249 | } 250 | } 251 | } 252 | }, 253 | "delete": { 254 | "security": [ 255 | { 256 | "BearerAuth": [] 257 | } 258 | ], 259 | "description": "Deletes a user's shortened URL by code", 260 | "produces": [ 261 | "application/json" 262 | ], 263 | "tags": [ 264 | "URL" 265 | ], 266 | "summary": "Delete a shortened URL", 267 | "parameters": [ 268 | { 269 | "type": "string", 270 | "description": "Short URL code", 271 | "name": "code", 272 | "in": "path", 273 | "required": true 274 | } 275 | ], 276 | "responses": { 277 | "200": { 278 | "description": "OK", 279 | "schema": { 280 | "$ref": "#/definitions/response.SuccessResponse" 281 | } 282 | }, 283 | "403": { 284 | "description": "Forbidden", 285 | "schema": { 286 | "$ref": "#/definitions/response.ErrorResponse" 287 | } 288 | } 289 | } 290 | }, 291 | "patch": { 292 | "security": [ 293 | { 294 | "BearerAuth": [] 295 | } 296 | ], 297 | "description": "Updates original URL or custom code for a user's URL", 298 | "consumes": [ 299 | "application/json" 300 | ], 301 | "produces": [ 302 | "application/json" 303 | ], 304 | "tags": [ 305 | "URL" 306 | ], 307 | "summary": "Update a shortened URL", 308 | "parameters": [ 309 | { 310 | "type": "string", 311 | "description": "Short URL code", 312 | "name": "code", 313 | "in": "path", 314 | "required": true 315 | }, 316 | { 317 | "description": "Updated URL info", 318 | "name": "request", 319 | "in": "body", 320 | "required": true, 321 | "schema": { 322 | "$ref": "#/definitions/request.UpdateURLRequest" 323 | } 324 | } 325 | ], 326 | "responses": { 327 | "200": { 328 | "description": "OK", 329 | "schema": { 330 | "$ref": "#/definitions/response.SuccessResponse" 331 | } 332 | }, 333 | "400": { 334 | "description": "Bad Request", 335 | "schema": { 336 | "$ref": "#/definitions/response.ErrorResponse" 337 | } 338 | }, 339 | "403": { 340 | "description": "Forbidden", 341 | "schema": { 342 | "$ref": "#/definitions/response.ErrorResponse" 343 | } 344 | } 345 | } 346 | } 347 | }, 348 | "/password/reset": { 349 | "get": { 350 | "security": [ 351 | { 352 | "BearerAuth": [] 353 | } 354 | ], 355 | "description": "Sends password reset link to user's email", 356 | "produces": [ 357 | "application/json" 358 | ], 359 | "tags": [ 360 | "Auth" 361 | ], 362 | "summary": "Send password reset mail", 363 | "responses": { 364 | "200": { 365 | "description": "OK", 366 | "schema": { 367 | "$ref": "#/definitions/response.SuccessResponse" 368 | } 369 | }, 370 | "404": { 371 | "description": "Not Found", 372 | "schema": { 373 | "$ref": "#/definitions/response.ErrorResponse" 374 | } 375 | }, 376 | "500": { 377 | "description": "Internal Server Error", 378 | "schema": { 379 | "$ref": "#/definitions/response.ErrorResponse" 380 | } 381 | } 382 | } 383 | } 384 | }, 385 | "/register": { 386 | "post": { 387 | "description": "Creates a new user account", 388 | "consumes": [ 389 | "application/json" 390 | ], 391 | "produces": [ 392 | "application/json" 393 | ], 394 | "tags": [ 395 | "Auth" 396 | ], 397 | "summary": "User Registration", 398 | "parameters": [ 399 | { 400 | "description": "User Credentials", 401 | "name": "request", 402 | "in": "body", 403 | "required": true, 404 | "schema": { 405 | "$ref": "#/definitions/request.AuthRequest" 406 | } 407 | } 408 | ], 409 | "responses": { 410 | "201": { 411 | "description": "Created", 412 | "schema": { 413 | "$ref": "#/definitions/response.UserResponse" 414 | } 415 | }, 416 | "400": { 417 | "description": "Bad Request", 418 | "schema": { 419 | "$ref": "#/definitions/response.ErrorResponse" 420 | } 421 | } 422 | } 423 | } 424 | }, 425 | "/shorten": { 426 | "post": { 427 | "security": [ 428 | { 429 | "BearerAuth": [] 430 | } 431 | ], 432 | "description": "Create a shortened URL with optional custom code", 433 | "consumes": [ 434 | "application/json" 435 | ], 436 | "produces": [ 437 | "application/json" 438 | ], 439 | "tags": [ 440 | "URL" 441 | ], 442 | "summary": "Shorten a URL", 443 | "parameters": [ 444 | { 445 | "description": "URL to shorten", 446 | "name": "request", 447 | "in": "body", 448 | "required": true, 449 | "schema": { 450 | "$ref": "#/definitions/request.ShortenURLRequest" 451 | } 452 | } 453 | ], 454 | "responses": { 455 | "201": { 456 | "description": "Created", 457 | "schema": { 458 | "$ref": "#/definitions/response.URLResponse" 459 | } 460 | }, 461 | "400": { 462 | "description": "Bad Request", 463 | "schema": { 464 | "$ref": "#/definitions/response.ErrorResponse" 465 | } 466 | } 467 | } 468 | } 469 | }, 470 | "/verify/mail/{token}": { 471 | "get": { 472 | "description": "Validates email address through verification token", 473 | "produces": [ 474 | "application/json" 475 | ], 476 | "tags": [ 477 | "Verification" 478 | ], 479 | "summary": "Verify user's email address", 480 | "parameters": [ 481 | { 482 | "type": "string", 483 | "description": "Verification Token", 484 | "name": "token", 485 | "in": "path", 486 | "required": true 487 | } 488 | ], 489 | "responses": { 490 | "200": { 491 | "description": "OK", 492 | "schema": { 493 | "$ref": "#/definitions/response.SuccessResponse" 494 | } 495 | }, 496 | "404": { 497 | "description": "Not Found", 498 | "schema": { 499 | "$ref": "#/definitions/response.ErrorResponse" 500 | } 501 | }, 502 | "500": { 503 | "description": "Internal Server Error", 504 | "schema": { 505 | "$ref": "#/definitions/response.ErrorResponse" 506 | } 507 | } 508 | } 509 | } 510 | }, 511 | "/verify/password": { 512 | "post": { 513 | "security": [ 514 | { 515 | "BearerAuth": [] 516 | } 517 | ], 518 | "description": "Sets new password after token verification", 519 | "consumes": [ 520 | "application/json" 521 | ], 522 | "produces": [ 523 | "application/json" 524 | ], 525 | "tags": [ 526 | "Verification" 527 | ], 528 | "summary": "Reset user password with verification token", 529 | "parameters": [ 530 | { 531 | "description": "New Password", 532 | "name": "request", 533 | "in": "body", 534 | "required": true, 535 | "schema": { 536 | "$ref": "#/definitions/request.NewPassword" 537 | } 538 | } 539 | ], 540 | "responses": { 541 | "200": { 542 | "description": "OK", 543 | "schema": { 544 | "$ref": "#/definitions/response.SuccessResponse" 545 | } 546 | }, 547 | "400": { 548 | "description": "Bad Request", 549 | "schema": { 550 | "$ref": "#/definitions/response.ErrorResponse" 551 | } 552 | }, 553 | "404": { 554 | "description": "Not Found", 555 | "schema": { 556 | "$ref": "#/definitions/response.ErrorResponse" 557 | } 558 | }, 559 | "500": { 560 | "description": "Internal Server Error", 561 | "schema": { 562 | "$ref": "#/definitions/response.ErrorResponse" 563 | } 564 | } 565 | } 566 | } 567 | }, 568 | "/verify/password/{token}": { 569 | "get": { 570 | "description": "Sets new password after token verification", 571 | "consumes": [ 572 | "application/json" 573 | ], 574 | "produces": [ 575 | "application/json" 576 | ], 577 | "tags": [ 578 | "Verification" 579 | ], 580 | "summary": "Return verification token to use reset user password", 581 | "parameters": [ 582 | { 583 | "type": "string", 584 | "description": "Verification Token", 585 | "name": "token", 586 | "in": "path", 587 | "required": true 588 | } 589 | ], 590 | "responses": { 591 | "200": { 592 | "description": "OK", 593 | "schema": { 594 | "$ref": "#/definitions/response.SuccessResponse" 595 | } 596 | } 597 | } 598 | } 599 | }, 600 | "/{code}": { 601 | "get": { 602 | "description": "Redirects to original URL based on short code", 603 | "tags": [ 604 | "URL" 605 | ], 606 | "summary": "Redirect short URL to original URL", 607 | "parameters": [ 608 | { 609 | "type": "string", 610 | "description": "Short URL code", 611 | "name": "code", 612 | "in": "path", 613 | "required": true 614 | } 615 | ], 616 | "responses": { 617 | "302": { 618 | "description": "Redirects to original URL", 619 | "schema": { 620 | "type": "string" 621 | } 622 | }, 623 | "404": { 624 | "description": "Not Found", 625 | "schema": { 626 | "$ref": "#/definitions/response.ErrorResponse" 627 | } 628 | } 629 | } 630 | } 631 | } 632 | }, 633 | "definitions": { 634 | "entity.URL": { 635 | "type": "object", 636 | "properties": { 637 | "code": { 638 | "type": "string" 639 | }, 640 | "original_url": { 641 | "type": "string" 642 | }, 643 | "total_clicks": { 644 | "type": "integer" 645 | }, 646 | "user_id": { 647 | "type": "integer" 648 | } 649 | } 650 | }, 651 | "entity.User": { 652 | "type": "object", 653 | "properties": { 654 | "email": { 655 | "type": "string" 656 | }, 657 | "is_admin": { 658 | "type": "boolean" 659 | }, 660 | "is_verified": { 661 | "type": "boolean" 662 | }, 663 | "name": { 664 | "type": "string" 665 | }, 666 | "surname": { 667 | "type": "string" 668 | } 669 | } 670 | }, 671 | "request.AuthRequest": { 672 | "type": "object", 673 | "required": [ 674 | "email", 675 | "password" 676 | ], 677 | "properties": { 678 | "email": { 679 | "type": "string", 680 | "example": "asko@kusko.com" 681 | }, 682 | "name": { 683 | "type": "string", 684 | "example": "Cem" 685 | }, 686 | "password": { 687 | "type": "string", 688 | "minLength": 8, 689 | "example": "supersecret" 690 | }, 691 | "surname": { 692 | "type": "string", 693 | "example": "Akan" 694 | } 695 | } 696 | }, 697 | "request.NewPassword": { 698 | "type": "object", 699 | "properties": { 700 | "password": { 701 | "type": "string", 702 | "example": "newsecurepassword" 703 | } 704 | } 705 | }, 706 | "request.ShortenURLRequest": { 707 | "type": "object", 708 | "properties": { 709 | "custom_code": { 710 | "type": "string", 711 | "example": "custom123" 712 | }, 713 | "original_url": { 714 | "type": "string", 715 | "example": "https://google.com" 716 | } 717 | } 718 | }, 719 | "request.UpdateURLRequest": { 720 | "type": "object", 721 | "properties": { 722 | "new_custom_code": { 723 | "type": "string", 724 | "example": "newcode123" 725 | }, 726 | "new_original_url": { 727 | "type": "string", 728 | "example": "https://updated.com" 729 | } 730 | } 731 | }, 732 | "response.DetailedURLResponse": { 733 | "type": "object", 734 | "properties": { 735 | "code": { 736 | "type": "string", 737 | "example": "abc123" 738 | }, 739 | "daily_clicks": { 740 | "type": "integer", 741 | "example": 10 742 | }, 743 | "original_url": { 744 | "type": "string", 745 | "example": "https://google.com" 746 | }, 747 | "total_clicks": { 748 | "type": "integer", 749 | "example": 42 750 | } 751 | } 752 | }, 753 | "response.ErrorResponse": { 754 | "type": "object", 755 | "properties": { 756 | "error": { 757 | "type": "string", 758 | "example": "invalid credentials" 759 | } 760 | } 761 | }, 762 | "response.HealthStatusResponse": { 763 | "type": "object", 764 | "properties": { 765 | "database": { 766 | "allOf": [ 767 | { 768 | "$ref": "#/definitions/response.Status" 769 | } 770 | ], 771 | "example": "healthy" 772 | }, 773 | "email": { 774 | "allOf": [ 775 | { 776 | "$ref": "#/definitions/response.Status" 777 | } 778 | ], 779 | "example": "healthy" 780 | }, 781 | "redis": { 782 | "allOf": [ 783 | { 784 | "$ref": "#/definitions/response.Status" 785 | } 786 | ], 787 | "example": "healthy" 788 | }, 789 | "status": { 790 | "allOf": [ 791 | { 792 | "$ref": "#/definitions/response.Status" 793 | } 794 | ], 795 | "example": "healthy" 796 | } 797 | } 798 | }, 799 | "response.LoginResponse": { 800 | "type": "object", 801 | "properties": { 802 | "email": { 803 | "type": "string", 804 | "example": "asko@kusko.com" 805 | }, 806 | "id": { 807 | "type": "integer", 808 | "example": 1 809 | }, 810 | "token": { 811 | "type": "string", 812 | "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." 813 | } 814 | } 815 | }, 816 | "response.Status": { 817 | "type": "string", 818 | "enum": [ 819 | "healthy", 820 | "degraded", 821 | "unhealthy" 822 | ], 823 | "x-enum-varnames": [ 824 | "StatusHealthy", 825 | "StatusDegraded", 826 | "StatusUnhealthy" 827 | ] 828 | }, 829 | "response.SuccessResponse": { 830 | "type": "object", 831 | "properties": { 832 | "success": { 833 | "type": "string", 834 | "example": "Operation successful" 835 | } 836 | } 837 | }, 838 | "response.URLResponse": { 839 | "type": "object", 840 | "properties": { 841 | "code": { 842 | "type": "string", 843 | "example": "abc123" 844 | }, 845 | "original_url": { 846 | "type": "string", 847 | "example": "https://google.com" 848 | }, 849 | "short_url": { 850 | "type": "string", 851 | "example": "https://localhost/abc123" 852 | } 853 | } 854 | }, 855 | "response.UserResponse": { 856 | "type": "object", 857 | "properties": { 858 | "email": { 859 | "type": "string", 860 | "example": "asko@kusko.com" 861 | }, 862 | "id": { 863 | "type": "integer", 864 | "example": 1 865 | } 866 | } 867 | }, 868 | "response.UserURLsResponse": { 869 | "type": "object", 870 | "properties": { 871 | "urls": { 872 | "type": "array", 873 | "items": { 874 | "$ref": "#/definitions/entity.URL" 875 | } 876 | }, 877 | "user": { 878 | "$ref": "#/definitions/entity.User" 879 | } 880 | } 881 | } 882 | } 883 | } -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /api 2 | definitions: 3 | entity.URL: 4 | properties: 5 | code: 6 | type: string 7 | original_url: 8 | type: string 9 | total_clicks: 10 | type: integer 11 | user_id: 12 | type: integer 13 | type: object 14 | entity.User: 15 | properties: 16 | email: 17 | type: string 18 | is_admin: 19 | type: boolean 20 | is_verified: 21 | type: boolean 22 | name: 23 | type: string 24 | surname: 25 | type: string 26 | type: object 27 | request.AuthRequest: 28 | properties: 29 | email: 30 | example: asko@kusko.com 31 | type: string 32 | name: 33 | example: Cem 34 | type: string 35 | password: 36 | example: supersecret 37 | minLength: 8 38 | type: string 39 | surname: 40 | example: Akan 41 | type: string 42 | required: 43 | - email 44 | - password 45 | type: object 46 | request.NewPassword: 47 | properties: 48 | password: 49 | example: newsecurepassword 50 | type: string 51 | type: object 52 | request.ShortenURLRequest: 53 | properties: 54 | custom_code: 55 | example: custom123 56 | type: string 57 | original_url: 58 | example: https://google.com 59 | type: string 60 | type: object 61 | request.UpdateURLRequest: 62 | properties: 63 | new_custom_code: 64 | example: newcode123 65 | type: string 66 | new_original_url: 67 | example: https://updated.com 68 | type: string 69 | type: object 70 | response.DetailedURLResponse: 71 | properties: 72 | code: 73 | example: abc123 74 | type: string 75 | daily_clicks: 76 | example: 10 77 | type: integer 78 | original_url: 79 | example: https://google.com 80 | type: string 81 | total_clicks: 82 | example: 42 83 | type: integer 84 | type: object 85 | response.ErrorResponse: 86 | properties: 87 | error: 88 | example: invalid credentials 89 | type: string 90 | type: object 91 | response.HealthStatusResponse: 92 | properties: 93 | database: 94 | allOf: 95 | - $ref: '#/definitions/response.Status' 96 | example: healthy 97 | email: 98 | allOf: 99 | - $ref: '#/definitions/response.Status' 100 | example: healthy 101 | redis: 102 | allOf: 103 | - $ref: '#/definitions/response.Status' 104 | example: healthy 105 | status: 106 | allOf: 107 | - $ref: '#/definitions/response.Status' 108 | example: healthy 109 | type: object 110 | response.LoginResponse: 111 | properties: 112 | email: 113 | example: asko@kusko.com 114 | type: string 115 | id: 116 | example: 1 117 | type: integer 118 | token: 119 | example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 120 | type: string 121 | type: object 122 | response.Status: 123 | enum: 124 | - healthy 125 | - degraded 126 | - unhealthy 127 | type: string 128 | x-enum-varnames: 129 | - StatusHealthy 130 | - StatusDegraded 131 | - StatusUnhealthy 132 | response.SuccessResponse: 133 | properties: 134 | success: 135 | example: Operation successful 136 | type: string 137 | type: object 138 | response.URLResponse: 139 | properties: 140 | code: 141 | example: abc123 142 | type: string 143 | original_url: 144 | example: https://google.com 145 | type: string 146 | short_url: 147 | example: https://localhost/abc123 148 | type: string 149 | type: object 150 | response.UserResponse: 151 | properties: 152 | email: 153 | example: asko@kusko.com 154 | type: string 155 | id: 156 | example: 1 157 | type: integer 158 | type: object 159 | response.UserURLsResponse: 160 | properties: 161 | urls: 162 | items: 163 | $ref: '#/definitions/entity.URL' 164 | type: array 165 | user: 166 | $ref: '#/definitions/entity.User' 167 | type: object 168 | host: localhost:3000 169 | info: 170 | contact: {} 171 | description: Enterprise grade URL shortening service. 172 | title: URL Shortener API 173 | version: "1.0" 174 | paths: 175 | /{code}: 176 | get: 177 | description: Redirects to original URL based on short code 178 | parameters: 179 | - description: Short URL code 180 | in: path 181 | name: code 182 | required: true 183 | type: string 184 | responses: 185 | "302": 186 | description: Redirects to original URL 187 | schema: 188 | type: string 189 | "404": 190 | description: Not Found 191 | schema: 192 | $ref: '#/definitions/response.ErrorResponse' 193 | summary: Redirect short URL to original URL 194 | tags: 195 | - URL 196 | /admin/users: 197 | get: 198 | description: Retrieves all users with their associated short URLs 199 | produces: 200 | - application/json 201 | responses: 202 | "200": 203 | description: OK 204 | schema: 205 | items: 206 | $ref: '#/definitions/response.UserURLsResponse' 207 | type: array 208 | "500": 209 | description: Internal Server Error 210 | schema: 211 | $ref: '#/definitions/response.ErrorResponse' 212 | security: 213 | - BearerAuth: [] 214 | summary: List all users and their URLs 215 | tags: 216 | - Admin 217 | /admin/users/{id}: 218 | delete: 219 | description: Deletes a user by ID and all associated short URLs & Redis entries 220 | parameters: 221 | - description: User ID 222 | in: path 223 | name: id 224 | required: true 225 | type: integer 226 | produces: 227 | - application/json 228 | responses: 229 | "200": 230 | description: OK 231 | schema: 232 | $ref: '#/definitions/response.SuccessResponse' 233 | "400": 234 | description: Bad Request 235 | schema: 236 | $ref: '#/definitions/response.ErrorResponse' 237 | "500": 238 | description: Internal Server Error 239 | schema: 240 | $ref: '#/definitions/response.ErrorResponse' 241 | security: 242 | - BearerAuth: [] 243 | summary: Delete a user and all related URLs 244 | tags: 245 | - Admin 246 | /health: 247 | get: 248 | description: Returns current health status of DB, Redis and Email services 249 | responses: 250 | "200": 251 | description: OK 252 | schema: 253 | $ref: '#/definitions/response.HealthStatusResponse' 254 | summary: Health Check 255 | tags: 256 | - Health 257 | /login: 258 | post: 259 | consumes: 260 | - application/json 261 | description: Authenticates a user and returns JWT token 262 | parameters: 263 | - description: User Credentials 264 | in: body 265 | name: request 266 | required: true 267 | schema: 268 | $ref: '#/definitions/request.AuthRequest' 269 | produces: 270 | - application/json 271 | responses: 272 | "201": 273 | description: Created 274 | schema: 275 | $ref: '#/definitions/response.LoginResponse' 276 | "400": 277 | description: Bad Request 278 | schema: 279 | $ref: '#/definitions/response.ErrorResponse' 280 | summary: User Login 281 | tags: 282 | - Auth 283 | /me: 284 | get: 285 | description: Returns authenticated user's profile info 286 | produces: 287 | - application/json 288 | responses: 289 | "200": 290 | description: OK 291 | schema: 292 | $ref: '#/definitions/response.UserResponse' 293 | "500": 294 | description: Internal Server Error 295 | schema: 296 | $ref: '#/definitions/response.ErrorResponse' 297 | security: 298 | - BearerAuth: [] 299 | summary: Get current user's profile 300 | tags: 301 | - Auth 302 | /my/urls: 303 | get: 304 | description: Retrieves all shortened URLs for authenticated user 305 | produces: 306 | - application/json 307 | responses: 308 | "200": 309 | description: OK 310 | schema: 311 | items: 312 | $ref: '#/definitions/response.URLResponse' 313 | type: array 314 | "500": 315 | description: Internal Server Error 316 | schema: 317 | $ref: '#/definitions/response.ErrorResponse' 318 | security: 319 | - BearerAuth: [] 320 | summary: Get user's URLs 321 | tags: 322 | - URL 323 | /my/urls/{code}: 324 | delete: 325 | description: Deletes a user's shortened URL by code 326 | parameters: 327 | - description: Short URL code 328 | in: path 329 | name: code 330 | required: true 331 | type: string 332 | produces: 333 | - application/json 334 | responses: 335 | "200": 336 | description: OK 337 | schema: 338 | $ref: '#/definitions/response.SuccessResponse' 339 | "403": 340 | description: Forbidden 341 | schema: 342 | $ref: '#/definitions/response.ErrorResponse' 343 | security: 344 | - BearerAuth: [] 345 | summary: Delete a shortened URL 346 | tags: 347 | - URL 348 | get: 349 | description: Retrieves a single short URL details with daily click count 350 | parameters: 351 | - description: Short URL code 352 | in: path 353 | name: code 354 | required: true 355 | type: string 356 | produces: 357 | - application/json 358 | responses: 359 | "200": 360 | description: OK 361 | schema: 362 | $ref: '#/definitions/response.DetailedURLResponse' 363 | "404": 364 | description: Not Found 365 | schema: 366 | $ref: '#/definitions/response.ErrorResponse' 367 | security: 368 | - BearerAuth: [] 369 | summary: Get a single URL detail 370 | tags: 371 | - URL 372 | patch: 373 | consumes: 374 | - application/json 375 | description: Updates original URL or custom code for a user's URL 376 | parameters: 377 | - description: Short URL code 378 | in: path 379 | name: code 380 | required: true 381 | type: string 382 | - description: Updated URL info 383 | in: body 384 | name: request 385 | required: true 386 | schema: 387 | $ref: '#/definitions/request.UpdateURLRequest' 388 | produces: 389 | - application/json 390 | responses: 391 | "200": 392 | description: OK 393 | schema: 394 | $ref: '#/definitions/response.SuccessResponse' 395 | "400": 396 | description: Bad Request 397 | schema: 398 | $ref: '#/definitions/response.ErrorResponse' 399 | "403": 400 | description: Forbidden 401 | schema: 402 | $ref: '#/definitions/response.ErrorResponse' 403 | security: 404 | - BearerAuth: [] 405 | summary: Update a shortened URL 406 | tags: 407 | - URL 408 | /password/reset: 409 | get: 410 | description: Sends password reset link to user's email 411 | produces: 412 | - application/json 413 | responses: 414 | "200": 415 | description: OK 416 | schema: 417 | $ref: '#/definitions/response.SuccessResponse' 418 | "404": 419 | description: Not Found 420 | schema: 421 | $ref: '#/definitions/response.ErrorResponse' 422 | "500": 423 | description: Internal Server Error 424 | schema: 425 | $ref: '#/definitions/response.ErrorResponse' 426 | security: 427 | - BearerAuth: [] 428 | summary: Send password reset mail 429 | tags: 430 | - Auth 431 | /register: 432 | post: 433 | consumes: 434 | - application/json 435 | description: Creates a new user account 436 | parameters: 437 | - description: User Credentials 438 | in: body 439 | name: request 440 | required: true 441 | schema: 442 | $ref: '#/definitions/request.AuthRequest' 443 | produces: 444 | - application/json 445 | responses: 446 | "201": 447 | description: Created 448 | schema: 449 | $ref: '#/definitions/response.UserResponse' 450 | "400": 451 | description: Bad Request 452 | schema: 453 | $ref: '#/definitions/response.ErrorResponse' 454 | summary: User Registration 455 | tags: 456 | - Auth 457 | /shorten: 458 | post: 459 | consumes: 460 | - application/json 461 | description: Create a shortened URL with optional custom code 462 | parameters: 463 | - description: URL to shorten 464 | in: body 465 | name: request 466 | required: true 467 | schema: 468 | $ref: '#/definitions/request.ShortenURLRequest' 469 | produces: 470 | - application/json 471 | responses: 472 | "201": 473 | description: Created 474 | schema: 475 | $ref: '#/definitions/response.URLResponse' 476 | "400": 477 | description: Bad Request 478 | schema: 479 | $ref: '#/definitions/response.ErrorResponse' 480 | security: 481 | - BearerAuth: [] 482 | summary: Shorten a URL 483 | tags: 484 | - URL 485 | /verify/mail/{token}: 486 | get: 487 | description: Validates email address through verification token 488 | parameters: 489 | - description: Verification Token 490 | in: path 491 | name: token 492 | required: true 493 | type: string 494 | produces: 495 | - application/json 496 | responses: 497 | "200": 498 | description: OK 499 | schema: 500 | $ref: '#/definitions/response.SuccessResponse' 501 | "404": 502 | description: Not Found 503 | schema: 504 | $ref: '#/definitions/response.ErrorResponse' 505 | "500": 506 | description: Internal Server Error 507 | schema: 508 | $ref: '#/definitions/response.ErrorResponse' 509 | summary: Verify user's email address 510 | tags: 511 | - Verification 512 | /verify/password: 513 | post: 514 | consumes: 515 | - application/json 516 | description: Sets new password after token verification 517 | parameters: 518 | - description: New Password 519 | in: body 520 | name: request 521 | required: true 522 | schema: 523 | $ref: '#/definitions/request.NewPassword' 524 | produces: 525 | - application/json 526 | responses: 527 | "200": 528 | description: OK 529 | schema: 530 | $ref: '#/definitions/response.SuccessResponse' 531 | "400": 532 | description: Bad Request 533 | schema: 534 | $ref: '#/definitions/response.ErrorResponse' 535 | "404": 536 | description: Not Found 537 | schema: 538 | $ref: '#/definitions/response.ErrorResponse' 539 | "500": 540 | description: Internal Server Error 541 | schema: 542 | $ref: '#/definitions/response.ErrorResponse' 543 | security: 544 | - BearerAuth: [] 545 | summary: Reset user password with verification token 546 | tags: 547 | - Verification 548 | /verify/password/{token}: 549 | get: 550 | consumes: 551 | - application/json 552 | description: Sets new password after token verification 553 | parameters: 554 | - description: Verification Token 555 | in: path 556 | name: token 557 | required: true 558 | type: string 559 | produces: 560 | - application/json 561 | responses: 562 | "200": 563 | description: OK 564 | schema: 565 | $ref: '#/definitions/response.SuccessResponse' 566 | summary: Return verification token to use reset user password 567 | tags: 568 | - Verification 569 | swagger: "2.0" 570 | -------------------------------------------------------------------------------- /email/assets/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CemAkan/url-shortener/9e3bd808af73f02e33a4de891767e81b51e3acb4/email/assets/header.png -------------------------------------------------------------------------------- /email/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CemAkan/url-shortener/9e3bd808af73f02e33a4de891767e81b51e3acb4/email/assets/logo.png -------------------------------------------------------------------------------- /email/embed.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "embed" 5 | "github.com/CemAkan/url-shortener/pkg/logger" 6 | ) 7 | 8 | //go:embed **/base.html 9 | var baseFile embed.FS 10 | 11 | var TemplateBasePath string 12 | 13 | func init() { 14 | candidates := []string{ 15 | "templates/base.html", 16 | "email/templates/base.html", 17 | } 18 | 19 | for _, path := range candidates { 20 | _, err := baseFile.Open(path) 21 | if err == nil { 22 | TemplateBasePath = path[:len(path)-len("base.html")] 23 | break 24 | } 25 | } 26 | 27 | if TemplateBasePath == "" { 28 | logger.Log.Fatalf("Could not detect template base path") 29 | } 30 | } 31 | 32 | //go:embed **/* 33 | var TemplatesFS embed.FS 34 | -------------------------------------------------------------------------------- /email/renderer.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "bytes" 5 | "github.com/CemAkan/url-shortener/pkg/logger" 6 | "html/template" 7 | "strings" 8 | ) 9 | 10 | type EmailData struct { 11 | Title string 12 | Greeting string 13 | Message string 14 | VerificationLink string 15 | LogoURL string 16 | HeaderURL string 17 | ButtonText string 18 | } 19 | 20 | func Render(data EmailData) (string, error) { 21 | files := []string{ 22 | TemplateBasePath + "base.html", 23 | TemplateBasePath + "components/logo.html", 24 | TemplateBasePath + "components/header.html", 25 | TemplateBasePath + "components/footer.html", 26 | TemplateBasePath + "transactional/content.html", 27 | } 28 | 29 | logger.Log.Infof("Parsing Templates: %s", strings.Join(files, ", ")) 30 | 31 | tmpl, err := template.New("base.html").ParseFS(TemplatesFS, files...) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | var buf bytes.Buffer 37 | err = tmpl.Execute(&buf, data) 38 | return buf.String(), err 39 | } 40 | -------------------------------------------------------------------------------- /email/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ .Title }} 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 22 | 23 |
15 | 16 | {{ template "logo" . }} 17 | {{ template "header" . }} 18 | {{ template "content" . }} 19 | {{ template "footer" . }} 20 |
21 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /email/templates/components/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 | 3 | 4 | 10 | 11 |
5 | 6 | 7 | 8 | Cem Akan © 2025 9 |
12 | {{ end }} -------------------------------------------------------------------------------- /email/templates/components/header.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 | 3 | 4 | 7 | 8 |
5 | Header Banner 6 |
9 | {{ end }} -------------------------------------------------------------------------------- /email/templates/components/logo.html: -------------------------------------------------------------------------------- 1 | {{ define "logo" }} 2 | 3 | 4 | 7 | 8 |
5 | URL Shortener 6 |
9 | {{ end }} -------------------------------------------------------------------------------- /email/templates/transactional/content.html: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 |
{{ .Greeting }}
{{ .Message }}
11 | {{ .ButtonText }} 12 |
15 | {{ end }} -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/CemAkan/url-shortener 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/ansrivas/fiberprometheus/v2 v2.9.1 9 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df 10 | github.com/gofiber/fiber/v2 v2.52.6 11 | github.com/golang-jwt/jwt/v5 v5.2.2 12 | github.com/joho/godotenv v1.5.1 13 | github.com/prometheus/client_golang v1.22.0 14 | github.com/redis/go-redis/v9 v9.8.0 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/swaggo/fiber-swagger v1.3.0 17 | github.com/swaggo/swag v1.16.4 18 | golang.org/x/crypto v0.38.0 19 | gorm.io/driver/postgres v1.5.11 20 | gorm.io/gorm v1.26.1 21 | ) 22 | 23 | require ( 24 | github.com/KyleBanks/depth v1.2.1 // indirect 25 | github.com/PuerkitoBio/purell v1.1.1 // indirect 26 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 27 | github.com/andybalholm/brotli v1.1.1 // indirect 28 | github.com/beorn7/perks v1.0.1 // indirect 29 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 30 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 31 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 32 | github.com/go-openapi/jsonreference v0.19.6 // indirect 33 | github.com/go-openapi/spec v0.20.4 // indirect 34 | github.com/go-openapi/swag v0.19.15 // indirect 35 | github.com/google/uuid v1.6.0 // indirect 36 | github.com/jackc/pgpassfile v1.0.0 // indirect 37 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 38 | github.com/jackc/pgx/v5 v5.5.5 // indirect 39 | github.com/jackc/puddle/v2 v2.2.1 // indirect 40 | github.com/jinzhu/inflection v1.0.0 // indirect 41 | github.com/jinzhu/now v1.1.5 // indirect 42 | github.com/josharian/intern v1.0.0 // indirect 43 | github.com/klauspost/compress v1.18.0 // indirect 44 | github.com/mailru/easyjson v0.7.6 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mattn/go-runewidth v0.0.16 // indirect 48 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 49 | github.com/prometheus/client_model v0.6.1 // indirect 50 | github.com/prometheus/common v0.62.0 // indirect 51 | github.com/prometheus/procfs v0.15.1 // indirect 52 | github.com/rivo/uniseg v0.4.7 // indirect 53 | github.com/rogpeppe/go-internal v1.14.1 // indirect 54 | github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect 55 | github.com/valyala/bytebufferpool v1.0.0 // indirect 56 | github.com/valyala/fasthttp v1.59.0 // indirect 57 | go.opentelemetry.io/otel v1.35.0 // indirect 58 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 59 | golang.org/x/net v0.40.0 // indirect 60 | golang.org/x/sync v0.14.0 // indirect 61 | golang.org/x/sys v0.33.0 // indirect 62 | golang.org/x/text v0.25.0 // indirect 63 | golang.org/x/tools v0.26.0 // indirect 64 | google.golang.org/protobuf v1.36.5 // indirect 65 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 66 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect 67 | gopkg.in/yaml.v2 v2.4.0 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 3 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 4 | github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= 5 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 6 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= 7 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 8 | github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 9 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 10 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 11 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 12 | github.com/ansrivas/fiberprometheus/v2 v2.9.1 h1:Ui1gPZRax1SNplReQ9G2xEdqEmu436T6hmIcdqorAqs= 13 | github.com/ansrivas/fiberprometheus/v2 v2.9.1/go.mod h1:j8NqXE0/WczX+65E/pCCqWngMfaLda85Hq+O9hg9odU= 14 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 15 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 16 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 17 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 18 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 19 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 20 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 21 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 22 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 23 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 29 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 30 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df h1:Bao6dhmbTA1KFVxmJ6nBoMuOJit2yjEgLJpIMYpop0E= 31 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= 32 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 33 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 34 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 35 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 36 | github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 37 | github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= 38 | github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 39 | github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= 40 | github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= 41 | github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= 42 | github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= 43 | github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= 44 | github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= 45 | github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 46 | github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= 47 | github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= 48 | github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 49 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 50 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 51 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 52 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 53 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 54 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 55 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 56 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 57 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 58 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 59 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 60 | github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= 61 | github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 62 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 63 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 64 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 65 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 66 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 67 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 68 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 69 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 70 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 71 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 72 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 73 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 74 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 75 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 76 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 77 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 78 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 79 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 80 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 81 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 82 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 83 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 84 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 85 | github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 86 | github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 87 | github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= 88 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 89 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 90 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 91 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 92 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 93 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 94 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 95 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 96 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 97 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 98 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 99 | github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= 100 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 101 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= 102 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 103 | github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 104 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 105 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 106 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 107 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 108 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 109 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 110 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 111 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 112 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 113 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 114 | github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= 115 | github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= 116 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 117 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 118 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 119 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 120 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 121 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 122 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 123 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 124 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 125 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 126 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 127 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 128 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 129 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 130 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 131 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 132 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 133 | github.com/swaggo/fiber-swagger v1.3.0 h1:RMjIVDleQodNVdKuu7GRs25Eq8RVXK7MwY9f5jbobNg= 134 | github.com/swaggo/fiber-swagger v1.3.0/go.mod h1:18MuDqBkYEiUmeM/cAAB8CI28Bi62d/mys39j1QqF9w= 135 | github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= 136 | github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= 137 | github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= 138 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 139 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 140 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 141 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 142 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 143 | github.com/valyala/fasthttp v1.35.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= 144 | github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= 145 | github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= 146 | github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= 147 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 148 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 149 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 150 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 151 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 152 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 153 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 154 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 155 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= 156 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= 157 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 158 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 159 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 160 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 161 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 162 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 163 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 164 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 165 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 166 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 167 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 168 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 169 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 170 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 171 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 172 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 173 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 174 | golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= 175 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 176 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 177 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 178 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 179 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 180 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 181 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 183 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 184 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 185 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 186 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 | golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 188 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 189 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 190 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 191 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 196 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 197 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 198 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 199 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 200 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 201 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 202 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 203 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 204 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 205 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 206 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 207 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 208 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 209 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 210 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 211 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 212 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 213 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 214 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 215 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 216 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 217 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 218 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 219 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 221 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 222 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 223 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 224 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 225 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 226 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 227 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 228 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 229 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 230 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 231 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 232 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 233 | gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= 234 | gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 235 | gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= 236 | gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= 237 | -------------------------------------------------------------------------------- /internal/delivery/http/handler/admin_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/response" 5 | "github.com/CemAkan/url-shortener/internal/service" 6 | "github.com/gofiber/fiber/v2" 7 | "strconv" 8 | ) 9 | 10 | type AdminHandler struct { 11 | userService service.UserService 12 | urlService service.URLService 13 | } 14 | 15 | // NewAdminHandler constructor 16 | func NewAdminHandler(userService service.UserService, urlService service.URLService) *AdminHandler { 17 | return &AdminHandler{ 18 | userService: userService, 19 | urlService: urlService, 20 | } 21 | } 22 | 23 | // ListUsers godoc 24 | // @Summary List all users and their URLs 25 | // @Description Retrieves all users with their associated short URLs 26 | // @Tags Admin 27 | // @Produce json 28 | // @Success 200 {array} response.UserURLsResponse 29 | // @Failure 500 {object} response.ErrorResponse 30 | // @Security BearerAuth 31 | // @Router /admin/users [get] 32 | func (h *AdminHandler) ListUsers(c *fiber.Ctx) error { 33 | users, err := h.userService.ListAllUsers() 34 | if err != nil { 35 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: err.Error()}) 36 | } 37 | 38 | var resps []response.UserURLsResponse 39 | 40 | for _, user := range users { 41 | urls, _ := h.urlService.GetUserURLs(user.ID) 42 | 43 | resps = append(resps, response.UserURLsResponse{ 44 | User: user, 45 | Urls: urls, 46 | }) 47 | } 48 | 49 | return c.JSON(resps) 50 | } 51 | 52 | // RemoveUser godoc 53 | // @Summary Delete a user and all related URLs 54 | // @Description Deletes a user by ID and all associated short URLs & Redis entries 55 | // @Tags Admin 56 | // @Param id path int true "User ID" 57 | // @Produce json 58 | // @Success 200 {object} response.SuccessResponse 59 | // @Failure 400 {object} response.ErrorResponse 60 | // @Failure 500 {object} response.ErrorResponse 61 | // @Security BearerAuth 62 | // @Router /admin/users/{id} [delete] 63 | func (h *AdminHandler) RemoveUser(c *fiber.Ctx) error { 64 | id, err := strconv.Atoi(c.Params("id")) 65 | if err != nil { 66 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid user id"}) 67 | } 68 | 69 | userID := uint(id) 70 | 71 | if err := h.urlService.DeleteUserAllURLs(userID); err != nil { 72 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: err.Error()}) 73 | } 74 | 75 | if err := h.userService.DeleteUser(userID); err != nil { 76 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: err.Error()}) 77 | } 78 | 79 | return c.JSON(response.SuccessResponse{Message: "user deleted successfully"}) 80 | } 81 | -------------------------------------------------------------------------------- /internal/delivery/http/handler/auth_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/config" 5 | "github.com/CemAkan/url-shortener/internal/domain/request" 6 | "github.com/CemAkan/url-shortener/internal/domain/response" 7 | "github.com/CemAkan/url-shortener/internal/service" 8 | "github.com/gofiber/fiber/v2" 9 | "time" 10 | ) 11 | 12 | var ( 13 | mailValidationLinkExpireTime = time.Duration(24 * time.Hour) 14 | passwordResetLinkExpireTime = time.Duration(15 * time.Minute) 15 | ) 16 | 17 | type AuthHandler struct { 18 | userService service.UserService 19 | mailService service.MailService 20 | } 21 | 22 | // NewAuthHandler creates a new AuthHandler struct with given UserService and MailService inputs 23 | func NewAuthHandler(userService service.UserService, mailService service.MailService) *AuthHandler { 24 | return &AuthHandler{ 25 | userService: userService, 26 | mailService: mailService, 27 | } 28 | } 29 | 30 | // Register godoc 31 | // @Summary User Registration 32 | // @Description Creates a new user account 33 | // @Tags Auth 34 | // @Accept json 35 | // @Produce json 36 | // @Param request body request.AuthRequest true "User Credentials" 37 | // @Success 201 {object} response.UserResponse 38 | // @Failure 400 {object} response.ErrorResponse 39 | // @Router /register [post] 40 | func (h *AuthHandler) Register(c *fiber.Ctx) error { 41 | var req request.AuthRequest 42 | 43 | if err := c.BodyParser(&req); err != nil { 44 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid request body"}) 45 | } 46 | 47 | user, err := h.userService.Register(req.Email, req.Password, req.Name, req.Surname) 48 | 49 | if err != nil { 50 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: err.Error()}) 51 | } 52 | 53 | // get baseURL from fiber 54 | baseURL := c.BaseURL() 55 | 56 | //verify link generator 57 | verifyLink, err := h.mailService.VerifyLinkGenerator(user.ID, baseURL+"/api/verify/mail", "email_verification", mailValidationLinkExpireTime) 58 | 59 | if err != nil { 60 | h.mailService.GetMailLogger().Warnf("verify token generation for %s mail address failed: %v", user.Email, err.Error()) 61 | } 62 | 63 | // email address verification mail sending 64 | if err := h.mailService.SendVerificationMail(user.Name, baseURL, user.Email, verifyLink); err != nil { 65 | h.mailService.GetMailLogger().Warnf("send verification mail to %s mail address failed: %v", user.Email, err.Error()) 66 | } 67 | 68 | var res response.UserResponse 69 | res.ID, res.Email = user.ID, user.Email 70 | 71 | return c.Status(fiber.StatusCreated).JSON(res) 72 | } 73 | 74 | // Login godoc 75 | // @Summary User Login 76 | // @Description Authenticates a user and returns JWT token 77 | // @Tags Auth 78 | // @Accept json 79 | // @Produce json 80 | // @Param request body request.AuthRequest true "User Credentials" 81 | // @Success 201 {object} response.LoginResponse 82 | // @Failure 400 {object} response.ErrorResponse 83 | // @Router /login [post] 84 | func (h *AuthHandler) Login(c *fiber.Ctx) error { 85 | var req request.AuthRequest 86 | 87 | if err := c.BodyParser(&req); err != nil { 88 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid request body"}) 89 | } 90 | 91 | user, err := h.userService.Login(req.Email, req.Password) 92 | 93 | if err != nil { 94 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: err.Error()}) 95 | } 96 | 97 | // generate jwt token 98 | token, err := config.GenerateToken(user.ID, time.Duration(24*time.Hour), "auth") 99 | if err != nil { 100 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: "failed to generate token"}) 101 | } 102 | 103 | var res response.LoginResponse 104 | res.ID, res.Email, res.Token = user.ID, user.Email, token 105 | 106 | return c.Status(fiber.StatusCreated).JSON(res) 107 | } 108 | 109 | // Me godoc 110 | // @Summary Get current user's profile 111 | // @Description Returns authenticated user's profile info 112 | // @Tags Auth 113 | // @Produce json 114 | // @Success 200 {object} response.UserResponse 115 | // @Failure 500 {object} response.ErrorResponse 116 | // @Security BearerAuth 117 | // @Router /me [get] 118 | func (h *AuthHandler) Me(c *fiber.Ctx) error { 119 | // getting userId which comes from middleware 120 | id := c.Locals("user_id").(uint) 121 | 122 | //user existence check 123 | 124 | user, err := h.userService.GetByID(id) 125 | 126 | if err != nil { 127 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: "User not found"}) 128 | } 129 | 130 | var res response.UserResponse 131 | res.ID, res.Email = user.ID, user.Email 132 | 133 | // success return with user's data 134 | return c.JSON(res) 135 | } 136 | 137 | // ResetPassword godoc 138 | // @Summary Send password reset mail 139 | // @Description Sends password reset link to user's email 140 | // @Tags Auth 141 | // @Produce json 142 | // @Success 200 {object} response.SuccessResponse 143 | // @Failure 404 {object} response.ErrorResponse 144 | // @Failure 500 {object} response.ErrorResponse 145 | // @Security BearerAuth 146 | // @Router /password/reset [get] 147 | func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error { 148 | userID := c.Locals("user_id").(uint) 149 | 150 | user, err := h.userService.GetByID(userID) 151 | 152 | if err != nil { 153 | return c.Status(fiber.StatusNotFound).JSON(response.ErrorResponse{Error: "user not found"}) 154 | } 155 | 156 | // get baseURL from fiber 157 | baseURL := c.BaseURL() 158 | 159 | //verify link generator 160 | verifyLink, err := h.mailService.VerifyLinkGenerator(userID, baseURL+"/api/verify/password", "password_reset_verification", passwordResetLinkExpireTime) 161 | 162 | if err != nil { 163 | h.mailService.GetMailLogger().Warnf("verify token generation to reset password failed for userId=%s: %v", user.ID, err.Error()) 164 | } 165 | 166 | // password reset mail sending 167 | if err := h.mailService.SendPasswordResetMail(user.Name, baseURL, user.Email, verifyLink); err != nil { 168 | h.mailService.GetMailLogger().Warnf("send password reset mail to %s mail address failed: %v", user.Email, err.Error()) 169 | } 170 | 171 | return c.Status(fiber.StatusOK).JSON(response.SuccessResponse{Message: "password reset mail send"}) 172 | } 173 | -------------------------------------------------------------------------------- /internal/delivery/http/handler/health_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/response" 5 | "github.com/CemAkan/url-shortener/internal/health" 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | // Health godoc 10 | // @Summary Health Check 11 | // @Description Returns current health status of DB, Redis and Email services 12 | // @Tags Health 13 | // @Success 200 {object} response.HealthStatusResponse 14 | // @Router /health [get] 15 | func Health(c *fiber.Ctx) error { 16 | dbStatus := response.StatusHealthy 17 | if !health.GetDBStatus() { 18 | dbStatus = response.StatusUnhealthy 19 | } 20 | 21 | redisStatus := response.StatusHealthy 22 | if !health.GetRedisStatus() { 23 | redisStatus = response.StatusUnhealthy 24 | } 25 | 26 | emailStatus := response.StatusHealthy 27 | if !health.GetEmailStatus() { 28 | emailStatus = response.StatusUnhealthy 29 | } 30 | status := response.StatusHealthy 31 | if dbStatus != response.StatusHealthy || redisStatus != response.StatusHealthy || emailStatus != response.StatusHealthy { 32 | status = response.StatusDegraded 33 | } 34 | 35 | return c.JSON(response.HealthStatusResponse{ 36 | Status: status, 37 | Database: dbStatus, 38 | Redis: redisStatus, 39 | Email: emailStatus, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /internal/delivery/http/handler/url_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/request" 5 | "github.com/CemAkan/url-shortener/internal/domain/response" 6 | "github.com/CemAkan/url-shortener/internal/service" 7 | "github.com/CemAkan/url-shortener/pkg/utils" 8 | "github.com/gofiber/fiber/v2" 9 | "strings" 10 | ) 11 | 12 | type URLHandler struct { 13 | service service.URLService 14 | } 15 | 16 | func NewURLHandler(urlService service.URLService) *URLHandler { 17 | return &URLHandler{ 18 | service: urlService, 19 | } 20 | } 21 | 22 | // Shorten godoc 23 | // @Summary Shorten a URL 24 | // @Description Create a shortened URL with optional custom code 25 | // @Tags URL 26 | // @Accept json 27 | // @Produce json 28 | // @Param request body request.ShortenURLRequest true "URL to shorten" 29 | // @Success 201 {object} response.URLResponse 30 | // @Failure 400 {object} response.ErrorResponse 31 | // @Security BearerAuth 32 | // @Router /shorten [post] 33 | func (h *URLHandler) Shorten(c *fiber.Ctx) error { 34 | var req request.ShortenURLRequest 35 | 36 | if err := c.BodyParser(&req); err != nil { 37 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid request"}) 38 | } 39 | 40 | userID := c.Locals("user_id").(uint) 41 | 42 | url, err := h.service.Shorten(req.OriginalURL, userID, req.CustomCode) 43 | 44 | if err != nil { 45 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: err.Error()}) 46 | } 47 | 48 | var res response.URLResponse 49 | res.Code, res.OriginalURL, res.ShortURL = url.Code, url.OriginalURL, c.BaseURL()+"/"+url.Code 50 | 51 | return c.Status(fiber.StatusCreated).JSON(res) 52 | } 53 | 54 | // ListUserURLs godoc 55 | // @Summary Get user's URLs 56 | // @Description Retrieves all shortened URLs for authenticated user 57 | // @Tags URL 58 | // @Produce json 59 | // @Success 200 {array} response.URLResponse 60 | // @Failure 500 {object} response.ErrorResponse 61 | // @Security BearerAuth 62 | // @Router /my/urls [get] 63 | func (h *URLHandler) ListUserURLs(c *fiber.Ctx) error { 64 | userID := c.Locals("user_id").(uint) 65 | 66 | urls, err := h.service.GetUserURLs(userID) 67 | 68 | if err != nil { 69 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: err.Error()}) 70 | } 71 | 72 | return c.Status(fiber.StatusCreated).JSON(urls) 73 | } 74 | 75 | // GetSingleURL godoc 76 | // @Summary Get a single URL detail 77 | // @Description Retrieves a single short URL details with daily click count 78 | // @Tags URL 79 | // @Produce json 80 | // @Param code path string true "Short URL code" 81 | // @Success 200 {object} response.DetailedURLResponse 82 | // @Failure 404 {object} response.ErrorResponse 83 | // @Security BearerAuth 84 | // @Router /my/urls/{code} [get] 85 | func (h *URLHandler) GetSingleURL(c *fiber.Ctx) error { 86 | code := c.Params("code") 87 | 88 | url, dailyClicks, err := h.service.GetSingleUrlRecord(code) 89 | if err != nil || url == nil { 90 | return c.Status(fiber.StatusNotFound).JSON(response.ErrorResponse{Error: "not found"}) 91 | } 92 | 93 | var res response.DetailedURLResponse 94 | res.OriginalURL, res.Code, res.TotalClicks, res.DailyClicks = url.OriginalURL, url.Code, url.TotalClicks, dailyClicks 95 | 96 | return c.Status(fiber.StatusFound).JSON(res) 97 | } 98 | 99 | // Redirect godoc 100 | // @Summary Redirect short URL to original URL 101 | // @Description Redirects to original URL based on short code 102 | // @Tags URL 103 | // @Param code path string true "Short URL code" 104 | // @Success 302 {string} string "Redirects to original URL" 105 | // @Failure 404 {object} response.ErrorResponse 106 | // @Router /{code} [get] 107 | func (h *URLHandler) Redirect(c *fiber.Ctx) error { 108 | 109 | code := c.Params("code") 110 | ctx := c.Context() 111 | 112 | //code resolving 113 | originalURL, err := h.service.ResolveRedirect(ctx, code) 114 | if err != nil { 115 | return c.Status(fiber.StatusNotFound).JSON(response.ErrorResponse{Error: "Short url not found"}) 116 | } 117 | 118 | //daily click increment 119 | go utils.TrackClick(ctx, code) 120 | 121 | //http-s check 122 | if !strings.HasPrefix(originalURL, "http://") && !strings.HasPrefix(originalURL, "https://") { 123 | originalURL = "https://" + originalURL 124 | } 125 | 126 | return c.Redirect(originalURL, fiber.StatusFound) 127 | } 128 | 129 | // DeleteURL godoc 130 | // @Summary Delete a shortened URL 131 | // @Description Deletes a user's shortened URL by code 132 | // @Tags URL 133 | // @Produce json 134 | // @Param code path string true "Short URL code" 135 | // @Success 200 {object} response.SuccessResponse 136 | // @Failure 403 {object} response.ErrorResponse 137 | // @Security BearerAuth 138 | // @Router /my/urls/{code} [delete] 139 | func (h *URLHandler) DeleteURL(c *fiber.Ctx) error { 140 | userID := c.Locals("user_id").(uint) 141 | code := c.Params("code") 142 | 143 | if err := h.service.DeleteUserURL(userID, code); err != nil { 144 | return c.Status(fiber.StatusForbidden).JSON(response.ErrorResponse{Error: err.Error()}) 145 | } 146 | 147 | return c.JSON(response.SuccessResponse{Message: "user deleted successfully"}) 148 | } 149 | 150 | // UpdateURL godoc 151 | // @Summary Update a shortened URL 152 | // @Description Updates original URL or custom code for a user's URL 153 | // @Tags URL 154 | // @Accept json 155 | // @Produce json 156 | // @Param code path string true "Short URL code" 157 | // @Param request body request.UpdateURLRequest true "Updated URL info" 158 | // @Success 200 {object} response.SuccessResponse 159 | // @Failure 400 {object} response.ErrorResponse 160 | // @Failure 403 {object} response.ErrorResponse 161 | // @Security BearerAuth 162 | // @Router /my/urls/{code} [patch] 163 | func (h *URLHandler) UpdateURL(c *fiber.Ctx) error { 164 | var req request.UpdateURLRequest 165 | 166 | if err := c.BodyParser(&req); err != nil { 167 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid request"}) 168 | } 169 | 170 | userID := c.Locals("user_id").(uint) 171 | code := c.Params("code") 172 | 173 | if err := h.service.UpdateUserURL(userID, code, req.NewOriginalURL, req.NewCustomCode); err != nil { 174 | return c.Status(fiber.StatusForbidden).JSON(response.ErrorResponse{Error: err.Error()}) 175 | } 176 | 177 | return c.JSON(response.SuccessResponse{Message: "user updated successfully"}) 178 | 179 | } 180 | -------------------------------------------------------------------------------- /internal/delivery/http/handler/verification_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/request" 5 | "github.com/CemAkan/url-shortener/internal/domain/response" 6 | "github.com/CemAkan/url-shortener/internal/service" 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | type VerificationHandler struct { 11 | userService service.UserService 12 | } 13 | 14 | // NewVerificationHandler generate a new VerificationHandler struct with given UserService and mailService inputs 15 | func NewVerificationHandler(userService service.UserService) *VerificationHandler { 16 | return &VerificationHandler{ 17 | userService: userService, 18 | } 19 | } 20 | 21 | // VerifyMailAddress godoc 22 | // @Summary Verify user's email address 23 | // @Description Validates email address through verification token 24 | // @Tags Verification 25 | // @Produce json 26 | // @Param token path string true "Verification Token" 27 | // @Success 200 {object} response.SuccessResponse 28 | // @Failure 404 {object} response.ErrorResponse 29 | // @Failure 500 {object} response.ErrorResponse 30 | // @Router /verify/mail/{token} [get] 31 | func (h *VerificationHandler) VerifyMailAddress(c *fiber.Ctx) error { 32 | userID := c.Locals("user_id").(uint) 33 | 34 | if _, err := h.userService.GetByID(userID); err != nil { 35 | return c.Status(fiber.StatusNotFound).JSON(response.ErrorResponse{Error: "User not found"}) 36 | } 37 | 38 | if err := h.userService.SetTrueEmailConfirmation(userID); err != nil { 39 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: "Database error"}) 40 | } 41 | 42 | return c.Status(fiber.StatusOK).JSON(response.SuccessResponse{Message: "mail confirmation successfully"}) 43 | } 44 | 45 | // ResetPassword godoc 46 | // @Summary Reset user password with verification token 47 | // @Description Sets new password after token verification 48 | // @Tags Verification 49 | // @Accept json 50 | // @Produce json 51 | // @Param request body request.NewPassword true "New Password" 52 | // @Success 200 {object} response.SuccessResponse 53 | // @Failure 404 {object} response.ErrorResponse 54 | // @Failure 400 {object} response.ErrorResponse 55 | // @Failure 500 {object} response.ErrorResponse 56 | // @Security BearerAuth 57 | // @Router /verify/password [post] 58 | func (h *VerificationHandler) ResetPassword(c *fiber.Ctx) error { 59 | userID := c.Locals("user_id").(uint) 60 | 61 | if _, err := h.userService.GetByID(userID); err != nil { 62 | return c.Status(fiber.StatusNotFound).JSON(response.ErrorResponse{Error: "User not found"}) 63 | } 64 | 65 | var req request.NewPassword //new password 66 | 67 | if err := c.BodyParser(&req); err != nil { 68 | return c.Status(fiber.StatusBadRequest).JSON(response.ErrorResponse{Error: "invalid request"}) 69 | } 70 | 71 | if err := h.userService.PasswordUpdate(userID, req.Password); err != nil { 72 | return c.Status(fiber.StatusInternalServerError).JSON(response.ErrorResponse{Error: "password update fail"}) 73 | } 74 | 75 | return c.Status(fiber.StatusOK).JSON(response.SuccessResponse{Message: "password updated"}) 76 | } 77 | 78 | // ResetPasswordTokenResolve godoc 79 | // @Summary Return verification token to use reset user password 80 | // @Description Sets new password after token verification 81 | // @Tags Verification 82 | // @Accept json 83 | // @Produce json 84 | // @Param token path string true "Verification Token" 85 | // @Success 200 {object} response.SuccessResponse 86 | // @Router /verify/password/{token} [get] 87 | func (h *VerificationHandler) ResetPasswordTokenResolve(c *fiber.Ctx) error { 88 | tokenStr := c.Params("token") 89 | return c.Status(fiber.StatusOK).JSON(response.SuccessResponse{Message: tokenStr}) 90 | } 91 | -------------------------------------------------------------------------------- /internal/delivery/http/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/config" 5 | _ "github.com/CemAkan/url-shortener/docs" 6 | "github.com/CemAkan/url-shortener/internal/delivery/http/handler" 7 | "github.com/CemAkan/url-shortener/internal/delivery/middleware" 8 | "github.com/CemAkan/url-shortener/internal/metrics" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/prometheus/client_golang/prometheus" 11 | fiberSwagger "github.com/swaggo/fiber-swagger" 12 | ) 13 | 14 | func SetupRoutes(app *fiber.App, authHandler *handler.AuthHandler, urlHandler *handler.URLHandler, adminHandler *handler.AdminHandler, verificationHandler *handler.VerificationHandler) { 15 | // implement metrics middleware 16 | registry := prometheus.NewRegistry() 17 | 18 | metrics.RegisterAll(registry) 19 | 20 | middleware.SetupPrometheus(app, registry) 21 | 22 | // metric ip protection check 23 | if config.GetEnv("METRICS_PROTECT", "true") == "true" { 24 | app.Use("/metrics", middleware.IPWhitelistMiddleware()) 25 | } 26 | 27 | // Swagger UI Route 28 | app.Get("api/docs/*", fiberSwagger.WrapHandler) 29 | 30 | // swagger ip protection check 31 | if config.GetEnv("SWAGGER_PROTECT", "true") == "true" { 32 | app.Use("api/docs/*", middleware.IPWhitelistMiddleware()) 33 | } 34 | 35 | // implement log middleware 36 | app.Use(middleware.RequestLogger()) 37 | 38 | // -- public routes (no need jwt) -- 39 | api := app.Group("/api") 40 | 41 | //mail assets 42 | api.Static("/assets", "./email/assets") 43 | 44 | //health 45 | api.Get("/health", handler.Health) 46 | 47 | //verification 48 | api.Get("/verify/mail/:token", middleware.JWTVerification("email_verification"), verificationHandler.VerifyMailAddress) 49 | api.Get("/verify/password/:token", middleware.JWTVerification("password_reset_verification"), verificationHandler.ResetPasswordTokenResolve) 50 | api.Post("/verify/password", middleware.JWTAuth("password_reset_verification"), verificationHandler.ResetPassword) 51 | 52 | //auth 53 | api.Post("/register", authHandler.Register) 54 | api.Post("/login", authHandler.Login) 55 | 56 | // -- protected routes (jwt required) -- 57 | 58 | //user 59 | api.Get("/me", middleware.JWTAuth("auth"), authHandler.Me) 60 | api.Get("/password/reset", middleware.JWTAuth("auth"), authHandler.ResetPassword) 61 | 62 | //url 63 | api.Post("/shorten", middleware.JWTAuth("auth"), urlHandler.Shorten) 64 | api.Get("/my/urls", middleware.JWTAuth("auth"), urlHandler.ListUserURLs) 65 | api.Get("/my/urls/:code", middleware.JWTAuth("auth"), urlHandler.GetSingleURL) 66 | api.Delete("/my/urls/:code", middleware.JWTAuth("auth"), urlHandler.DeleteURL) 67 | api.Patch("/my/urls/:code", middleware.JWTAuth("auth"), urlHandler.UpdateURL) 68 | 69 | // Admin routes 70 | adminGroup := api.Group("/admin", middleware.JWTAuth("auth"), middleware.AdminOnly()) 71 | 72 | adminGroup.Get("/users", adminHandler.ListUsers) 73 | adminGroup.Delete("/users/:id", adminHandler.RemoveUser) 74 | 75 | //redirect 76 | api.Get("/:code", urlHandler.Redirect) 77 | } 78 | -------------------------------------------------------------------------------- /internal/delivery/middleware/admin_only.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/repository" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | func AdminOnly() fiber.Handler { 9 | return func(c *fiber.Ctx) error { 10 | userID := c.Locals("user_id").(uint) 11 | 12 | user, err := repository.NewUserRepository().FindByID(userID) 13 | if err != nil || user == nil { 14 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"}) 15 | } 16 | 17 | if !user.IsAdmin { 18 | return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "admin access required"}) 19 | } 20 | 21 | return c.Next() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/delivery/middleware/ip_whitelist.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/CemAkan/url-shortener/pkg/logger" 8 | "github.com/CemAkan/url-shortener/pkg/utils" 9 | "github.com/gofiber/fiber/v2" 10 | ) 11 | 12 | func IPWhitelistMiddleware() fiber.Handler { 13 | whitelist := os.Getenv("IP_WHITELIST") // comma-separated IPs 14 | allowedIPs := strings.Split(whitelist, ",") 15 | 16 | return func(c *fiber.Ctx) error { 17 | ip := utils.GetClientIP(c) 18 | 19 | if ip == "" { 20 | logger.Log.Warn("No client IP could be determined") 21 | return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) 22 | } 23 | 24 | for _, allowed := range allowedIPs { 25 | if strings.TrimSpace(ip) == strings.TrimSpace(allowed) { 26 | return c.Next() 27 | } 28 | } 29 | 30 | logger.Log.Warnf("Unauthorized IP: %s attempted to access protected route", ip) 31 | return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/delivery/middleware/jwt_auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/config" 5 | "github.com/CemAkan/url-shortener/internal/domain/response" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/golang-jwt/jwt/v5" 8 | "strings" 9 | ) 10 | 11 | func JWTAuth(purpose string) fiber.Handler { 12 | return func(c *fiber.Ctx) error { 13 | 14 | authHeader := c.Get("Authorization") 15 | 16 | //Authorization header format check 17 | if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { 18 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{Error: "Missing or invalid Authorization header"}) 19 | } 20 | 21 | // Trimming 22 | tokenStr := strings.TrimPrefix(authHeader, "Bearer ") 23 | tokenStr = strings.TrimSpace(tokenStr) 24 | 25 | // Token check 26 | token, err := config.ResolveToken(tokenStr) 27 | 28 | if err != nil || !token.Valid { 29 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{Error: "Invalid or expired token"}) 30 | 31 | } 32 | 33 | // map data claim 34 | claims, ok := token.Claims.(jwt.MapClaims) 35 | 36 | if !ok { 37 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{ 38 | Error: "Invalid token claims", 39 | }) 40 | } 41 | 42 | if claims["type"] != purpose { 43 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{Error: "invalid token type"}) 44 | } 45 | 46 | // userID claim 47 | 48 | userID, ok := claims["user_id"].(float64) 49 | 50 | if !ok { 51 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{ 52 | Error: "Invalid token user ID", 53 | }) 54 | } 55 | 56 | c.Locals("user_id", uint(userID)) 57 | 58 | return c.Next() 59 | 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/delivery/middleware/jwt_verification.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/config" 5 | "github.com/CemAkan/url-shortener/internal/domain/response" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/golang-jwt/jwt/v5" 8 | ) 9 | 10 | func JWTVerification(purpose string) fiber.Handler { 11 | return func(c *fiber.Ctx) error { 12 | 13 | tokenStr := c.Params("token") 14 | 15 | // Token check 16 | token, err := config.ResolveToken(tokenStr) 17 | 18 | if err != nil || !token.Valid { 19 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{Error: "Invalid or expired link"}) 20 | 21 | } 22 | 23 | // map data claim 24 | claims, ok := token.Claims.(jwt.MapClaims) 25 | 26 | if !ok { 27 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{ 28 | Error: "Invalid token claims", 29 | }) 30 | } 31 | 32 | if claims["type"] != purpose { 33 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{Error: "invalid token type"}) 34 | } 35 | 36 | // userID claim 37 | 38 | userID, ok := claims["user_id"].(float64) 39 | 40 | if !ok { 41 | return c.Status(fiber.StatusUnauthorized).JSON(response.ErrorResponse{ 42 | Error: "Invalid token user ID", 43 | }) 44 | } 45 | 46 | c.Locals("user_id", uint(userID)) 47 | 48 | return c.Next() 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/delivery/middleware/metrics.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/ansrivas/fiberprometheus/v2" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/prometheus/client_golang/prometheus" 10 | 11 | "github.com/CemAkan/url-shortener/internal/metrics" 12 | ) 13 | 14 | // SetupPrometheus sets prometheus custom middleware 15 | func SetupPrometheus(app *fiber.App, registry *prometheus.Registry) { 16 | prom := fiberprometheus.NewWithRegistry( 17 | registry, 18 | "url_shortener", // namespace 19 | "http", // subsystem 20 | "service", // label name 21 | nil, 22 | ) 23 | prom.RegisterAt(app, "/metrics") 24 | 25 | app.Use(func(c *fiber.Ctx) error { 26 | start := time.Now() 27 | err := c.Next() 28 | duration := time.Since(start).Seconds() 29 | 30 | status := strconv.Itoa(c.Response().StatusCode()) 31 | method := c.Method() 32 | 33 | metrics.HTTPRequestTotal.WithLabelValues(status, method).Inc() 34 | metrics.HTTPRequestDuration.WithLabelValues(status, method).Observe(duration) 35 | 36 | return err 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /internal/delivery/middleware/request_logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | logger2 "github.com/CemAkan/url-shortener/pkg/logger" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/gofiber/fiber/v2/middleware/logger" 7 | ) 8 | 9 | var ( 10 | logFileName = "server" 11 | ) 12 | 13 | func RequestLogger() fiber.Handler { 14 | file, err := logger2.FileOpener(logFileName) 15 | 16 | if err != nil { 17 | file = logger2.Log.Out 18 | } 19 | return logger.New(logger.Config{ 20 | Format: "[${time}] ${status} - ${method} ${path} - ${latency}\n", 21 | TimeFormat: "2006-01-02 15:04:05", 22 | Output: file, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /internal/domain/entity/url.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "gorm.io/gorm" 4 | 5 | type URL struct { 6 | gorm.Model `json:"-"` // ID, CreatedAt, UpdatedAt, DeletedAt 7 | Code string `gorm:"uniqueIndex;not null" json:"code"` 8 | OriginalURL string `gorm:"not null" json:"original_url"` 9 | UserID uint `gorm:"index" json:"user_id"` 10 | TotalClicks int `gorm:"default:0" json:"total_clicks"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/domain/entity/user.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "gorm.io/gorm" 4 | 5 | type User struct { 6 | gorm.Model `json:"-"` // id, created_at, updated_at, deleted_at 7 | Name string `gorm:"size:50;not null" json:"name"` 8 | Surname string `gorm:"size:50;not null" json:"surname"` 9 | Email string `gorm:"size:120;uniqueIndex;not null" json:"email"` 10 | Password string `gorm:"not null" json:"-"` 11 | IsAdmin bool `gorm:"default:false" json:"is_admin"` 12 | IsMailConfirmed bool `gorm:"default:false" json:"is_verified"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/domain/request/auth.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | // AuthRequest ➜ Kayıt / giriş istek gövdesi 4 | type AuthRequest struct { 5 | Name string `json:"name" example:"Cem"` 6 | Surname string `json:"surname" example:"Akan"` 7 | Email string `json:"email" validate:"required,email" example:"asko@kusko.com"` 8 | Password string `json:"password" validate:"required,min=8" example:"supersecret"` 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/request/url.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type ShortenURLRequest struct { 4 | OriginalURL string `json:"original_url" example:"https://google.com"` 5 | CustomCode *string `json:"custom_code,omitempty" example:"custom123"` 6 | } 7 | 8 | type UpdateURLRequest struct { 9 | NewOriginalURL *string `json:"new_original_url,omitempty" example:"https://updated.com"` 10 | NewCustomCode *string `json:"new_custom_code,omitempty" example:"newcode123"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/domain/request/verification.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type NewPassword struct { 4 | Password string `json:"password" example:"newsecurepassword"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/domain/response/admin.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "github.com/CemAkan/url-shortener/internal/domain/entity" 4 | 5 | type UserURLsResponse struct { 6 | User entity.User `json:"user"` 7 | Urls []entity.URL `json:"urls"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/domain/response/auth.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type LoginResponse struct { 4 | ID uint `json:"id" example:"1"` 5 | Email string `json:"email" example:"asko@kusko.com"` 6 | Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` 7 | } 8 | 9 | type UserResponse struct { 10 | ID uint `json:"id" example:"1"` 11 | Email string `json:"email" example:"asko@kusko.com"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/domain/response/error.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type ErrorResponse struct { 4 | Error string `json:"error" example:"invalid credentials"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/domain/response/health.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type Status string 4 | 5 | const ( 6 | StatusHealthy Status = "healthy" 7 | StatusDegraded Status = "degraded" 8 | StatusUnhealthy Status = "unhealthy" 9 | ) 10 | 11 | type HealthStatusResponse struct { 12 | Status Status `json:"status" example:"healthy"` 13 | Database Status `json:"database" example:"healthy"` 14 | Redis Status `json:"redis" example:"healthy"` 15 | Email Status `json:"email" example:"healthy"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/domain/response/success.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type SuccessResponse struct { 4 | Message string `json:"success" example:"Operation successful"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/domain/response/url.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | type URLResponse struct { 4 | Code string `json:"code" example:"abc123"` 5 | OriginalURL string `json:"original_url" example:"https://google.com"` 6 | ShortURL string `json:"short_url" example:"https://localhost/abc123"` 7 | } 8 | 9 | type DetailedURLResponse struct { 10 | Code string `json:"code" example:"abc123"` 11 | OriginalURL string `json:"original_url" example:"https://google.com"` 12 | TotalClicks int `json:"total_clicks" example:"42"` 13 | DailyClicks int `json:"daily_clicks" example:"10"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/health/state.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import "sync/atomic" 4 | 5 | var ( 6 | // 0 = down, 1 = up 7 | dbStatus atomic.Int32 8 | redisStatus atomic.Int32 9 | emailStatus atomic.Int32 10 | ) 11 | 12 | // SetDBStatus sets database status atomic safe 13 | func SetDBStatus(up bool) { 14 | if up { 15 | dbStatus.Store(1) 16 | } else { 17 | dbStatus.Store(0) 18 | } 19 | } 20 | 21 | // SetRedisStatus sets redis status atomic safe 22 | func SetRedisStatus(up bool) { 23 | if up { 24 | redisStatus.Store(1) 25 | } else { 26 | redisStatus.Store(0) 27 | } 28 | } 29 | 30 | // SetEmailStatus sets mail service status atomic safe 31 | func SetEmailStatus(up bool) { 32 | if up { 33 | emailStatus.Store(1) 34 | } else { 35 | emailStatus.Store(0) 36 | } 37 | } 38 | 39 | // GetDBStatus returns database status from atomic dbStatus status var 40 | func GetDBStatus() bool { 41 | return dbStatus.Load() == 1 42 | } 43 | 44 | // GetRedisStatus returns redis status from atomic redisStatus status var 45 | func GetRedisStatus() bool { 46 | return redisStatus.Load() == 1 47 | } 48 | 49 | // GetEmailStatus returns email service status from atomic emailStatus status var 50 | func GetEmailStatus() bool { 51 | return emailStatus.Load() == 1 52 | } 53 | -------------------------------------------------------------------------------- /internal/health/watchdog.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 6 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 7 | "github.com/CemAkan/url-shortener/internal/infrastructure/mail" 8 | "github.com/CemAkan/url-shortener/internal/metrics" 9 | "github.com/CemAkan/url-shortener/pkg/logger" 10 | "time" 11 | ) 12 | 13 | const ( 14 | HealthCheckInterval = 10 * time.Second 15 | ) 16 | 17 | var ( 18 | lastMailHealthCheck time.Time 19 | mailHealthCheckCooldownDuration = time.Minute * 5 20 | ) 21 | 22 | // StartWatchdog monitors DB & Redis health and cancels ctx when threshold exceeded 23 | func StartWatchdog(ctx context.Context) { 24 | ticker := time.NewTicker(HealthCheckInterval) 25 | 26 | SetEmailStatus(checkEmailHealth()) //initial start 27 | 28 | defer ticker.Stop() 29 | 30 | for { 31 | select { 32 | case <-ctx.Done(): 33 | logger.Log.Infof("Watchdog context cancelled, stopping health checks") 34 | return 35 | 36 | case <-ticker.C: 37 | //logger.Info("Watchdog tick: running health checks") 38 | 39 | SetDBStatus(checkDBHealth(ctx)) 40 | SetRedisStatus(checkRedisHealth(ctx)) 41 | 42 | if time.Since(lastMailHealthCheck) < mailHealthCheckCooldownDuration { 43 | SetEmailStatus(checkEmailHealth()) 44 | } 45 | 46 | } 47 | } 48 | } 49 | 50 | // checkDBHealth checks database health 51 | func checkDBHealth(ctx context.Context) bool { 52 | metrics.DBUp.Set(1) 53 | healthy := true 54 | 55 | // db health check 56 | sqlDB, err := db.DB.DB() 57 | if err != nil || sqlDB.PingContext(ctx) != nil { 58 | logger.Log.WithError(err).Errorf("Database healthcheck failed") 59 | healthy = false 60 | metrics.DBUp.Set(0) 61 | } 62 | 63 | return healthy 64 | } 65 | 66 | // checkRedisHealth checks redis health 67 | func checkRedisHealth(ctx context.Context) bool { 68 | metrics.RedisUp.Set(1) 69 | healthy := true 70 | 71 | //redis health check 72 | rCtx, cancel := context.WithTimeout(ctx, 2*time.Second) 73 | defer cancel() 74 | if err := cache.Redis.Ping(rCtx).Err(); err != nil { 75 | logger.Log.WithError(err).Errorf("Redis healthcheck failed") 76 | healthy = false 77 | metrics.RedisUp.Set(0) 78 | } 79 | 80 | return healthy 81 | } 82 | 83 | // checkEmailHealth checks email service health 84 | func checkEmailHealth() bool { 85 | metrics.MailUp.Set(1) 86 | healthy := true 87 | 88 | //email service health check 89 | conn, err := mail.Mail.Dialer.Dial() 90 | if err != nil { 91 | logger.Log.WithError(err).Errorf("Mail healthcheck failed") 92 | healthy = false 93 | metrics.MailUp.Set(0) 94 | } 95 | if err := conn.Close(); err != nil { 96 | logger.Log.WithError(err).Errorf("Tcp socket close error during mail service healthcheck: %v", err.Error()) 97 | } 98 | 99 | return healthy 100 | } 101 | -------------------------------------------------------------------------------- /internal/infrastructure/cache/redis.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "github.com/CemAkan/url-shortener/config" 6 | "github.com/CemAkan/url-shortener/pkg/logger" 7 | "github.com/redis/go-redis/v9" 8 | "time" 9 | ) 10 | 11 | var Redis *redis.Client 12 | 13 | func InitRedis() { 14 | addr := config.GetEnv("REDIS_ADDR", "") 15 | password := config.GetEnv("REDIS_PASSWORD", "") 16 | 17 | Redis = redis.NewClient(&redis.Options{ 18 | Addr: addr, 19 | Password: password, 20 | DB: 0, 21 | }) 22 | 23 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 24 | 25 | defer cancel() 26 | 27 | err := Redis.Ping(ctx).Err() 28 | 29 | if err != nil { 30 | logger.Log.Fatalf("Failed to connect to redis: %v", err) 31 | } 32 | 33 | logger.Log.Info("Redis connection established successfully") 34 | 35 | } 36 | -------------------------------------------------------------------------------- /internal/infrastructure/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/CemAkan/url-shortener/config" 6 | "github.com/CemAkan/url-shortener/internal/domain/entity" 7 | "github.com/CemAkan/url-shortener/pkg/logger" 8 | "gorm.io/driver/postgres" 9 | "gorm.io/gorm" 10 | gormLogger "gorm.io/gorm/logger" 11 | "log" 12 | "time" 13 | ) 14 | 15 | var DB *gorm.DB 16 | 17 | func InitDB() { 18 | dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", 19 | config.GetEnv("POSTGRES_HOST", "pg-shortener"), 20 | config.GetEnv("POSTGRES_USER", "username"), 21 | config.GetEnv("POSTGRES_PASSWORD", "password"), 22 | config.GetEnv("POSTGRES_DB", "urlShortener"), 23 | config.GetEnv("POSTGRES_PORT", "5432"), 24 | ) 25 | 26 | //log file 27 | dbLogFile, err := logger.FileOpener("database") 28 | if err != nil { 29 | dbLogFile = logger.MainLogFile 30 | } 31 | 32 | //logger configuration 33 | newLogger := gormLogger.New( 34 | log.New(dbLogFile, "\r\n", log.LstdFlags), 35 | gormLogger.Config{ 36 | SlowThreshold: time.Second, 37 | LogLevel: gormLogger.Warn, 38 | Colorful: false, 39 | }, 40 | ) 41 | 42 | //db open 43 | database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 44 | Logger: newLogger, 45 | }) 46 | 47 | if err != nil || database == nil { 48 | logger.Log.Fatalf("db connection error %v", err) 49 | } 50 | 51 | //auto migration 52 | if err := database.AutoMigrate(&entity.URL{}, &entity.User{}); err != nil { 53 | logger.Log.Fatalf("db connection error %v", err.Error()) 54 | } 55 | 56 | // set global db var 57 | DB = database 58 | 59 | } 60 | -------------------------------------------------------------------------------- /internal/infrastructure/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "crypto/tls" 5 | "github.com/CemAkan/url-shortener/config" 6 | "github.com/go-gomail/gomail" 7 | "strconv" 8 | ) 9 | 10 | type Mailer struct { 11 | Dialer *gomail.Dialer 12 | from string 13 | } 14 | 15 | var Mail *Mailer 16 | 17 | func InitMail() { 18 | 19 | port, _ := strconv.Atoi(config.GetEnv("SMTP_PORT", "")) 20 | host := config.GetEnv("SMTP_HOST", "") 21 | user := config.GetEnv("SMTP_USER", "") 22 | pass := config.GetEnv("SMTP_PASS", "") 23 | from := config.GetEnv("SMTP_FROM", "") 24 | 25 | dialer := gomail.NewDialer(host, port, user, pass) 26 | 27 | dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true} 28 | 29 | Mail = &Mailer{ 30 | Dialer: dialer, 31 | from: from, 32 | } 33 | } 34 | 35 | // Send sends email 36 | func (m *Mailer) Send(to, subject, body string) error { 37 | msg := gomail.NewMessage() 38 | msg.SetHeader("From", m.from) 39 | msg.SetHeader("To", to) 40 | msg.SetHeader("Subject", subject) 41 | msg.SetBody("text/html", body) 42 | 43 | return m.Dialer.DialAndSend(msg) 44 | 45 | } 46 | -------------------------------------------------------------------------------- /internal/jobs/click_flush_job.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/service" 5 | "github.com/CemAkan/url-shortener/pkg/logger" 6 | "time" 7 | ) 8 | 9 | func StartClickFlushJob(flusher *service.ClickFlusherService, interval time.Duration) { 10 | ticker := time.NewTicker(interval) 11 | defer ticker.Stop() 12 | 13 | logger.Log.Infof("Click Flusher Job started with interval: %s", interval) 14 | 15 | for { 16 | <-ticker.C 17 | flusher.FlushClicks() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/metrics/prometheus.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | var ( 6 | // Health metrics 7 | DBUp = prometheus.NewGauge(prometheus.GaugeOpts{ 8 | Name: "db_up", Help: "Database connection status (1 = up, 0 = down)", 9 | }) 10 | RedisUp = prometheus.NewGauge(prometheus.GaugeOpts{ 11 | Name: "redis_up", Help: "Redis connection status (1 = up, 0 = down)", 12 | }) 13 | MailUp = prometheus.NewGauge(prometheus.GaugeOpts{ 14 | Name: "mail_up", Help: "Mail service connection status (1 = up, 0 = down)", 15 | }) 16 | 17 | // HTTP metrics 18 | HTTPRequestTotal = prometheus.NewCounterVec( 19 | prometheus.CounterOpts{ 20 | Name: "http_requests_total", 21 | Help: "Total HTTP requests processed, labeled by status code and method", 22 | }, 23 | []string{"status_code", "method"}, 24 | ) 25 | HTTPRequestDuration = prometheus.NewHistogramVec( 26 | prometheus.HistogramOpts{ 27 | Name: "http_request_duration_seconds", 28 | Help: "Histogram of HTTP request duration in seconds", 29 | Buckets: prometheus.DefBuckets, 30 | }, 31 | []string{"status_code", "method"}, 32 | ) 33 | ) 34 | 35 | // RegisterAll, verilen Registry üzerinde tüm metrikleri Tekrar kayda engelleyecek şekilde register eder. 36 | func RegisterAll(registry *prometheus.Registry) { 37 | registry.MustRegister( 38 | DBUp, 39 | RedisUp, 40 | MailUp, 41 | HTTPRequestTotal, 42 | HTTPRequestDuration, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /internal/repository/url_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/entity" 5 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type URLRepository interface { 10 | Create(*entity.URL) error 11 | FindByCode(code string) (*entity.URL, error) 12 | FindByUserID(id uint) ([]entity.URL, error) 13 | Update(url *entity.URL) error 14 | AddToTotalClicks(code string, count int) error 15 | Delete(code string) error 16 | DeleteUserAllUrls(userID uint) ([]entity.URL, error) 17 | } 18 | 19 | type urlRepo struct { 20 | db *gorm.DB 21 | } 22 | 23 | func NewURLRepository() URLRepository { 24 | return &urlRepo{ 25 | db: db.DB, 26 | } 27 | } 28 | 29 | // Create inserts new url 30 | func (r *urlRepo) Create(url *entity.URL) error { 31 | return r.db.Create(url).Error 32 | } 33 | 34 | // FindByCode retrieves URL by short code 35 | func (r *urlRepo) FindByCode(code string) (*entity.URL, error) { 36 | var url entity.URL 37 | err := r.db.Where("code = ?", code).First(&url).Error 38 | 39 | return &url, err 40 | } 41 | 42 | // FindByUserID retrieves all URLs which associated with UserID 43 | func (r *urlRepo) FindByUserID(id uint) ([]entity.URL, error) { 44 | var urls []entity.URL 45 | 46 | err := r.db.Where("user_id = ?", id).Find(&urls).Error 47 | 48 | return urls, err 49 | } 50 | 51 | // Update modifies to existing url 52 | func (r *urlRepo) Update(url *entity.URL) error { 53 | return r.db.Save(url).Error 54 | } 55 | 56 | // AddToTotalClicks adds wanted click count to total clicks 57 | func (r *urlRepo) AddToTotalClicks(code string, count int) error { 58 | return r.db.Model(&entity.URL{}).Where("code = ?", code).UpdateColumn("total_clicks", gorm.Expr("total_clicks + ?", count)).Error 59 | } 60 | 61 | // Delete removes code related url record from database 62 | func (r *urlRepo) Delete(code string) error { 63 | if err := r.db.Where("code= ?", code).Delete(&entity.URL{}).Error; err != nil { 64 | return err 65 | } 66 | return nil 67 | } 68 | 69 | // DeleteUserAllUrls removes all userId related url records from database 70 | func (r *urlRepo) DeleteUserAllUrls(userID uint) ([]entity.URL, error) { 71 | var urls []entity.URL 72 | 73 | if err := r.db.Where("user_id = ?", userID).Find(&urls).Error; err != nil { 74 | return nil, err 75 | } 76 | 77 | if err := r.db.Where("user_id = ?", userID).Delete(&entity.URL{}).Error; err != nil { 78 | return nil, err 79 | } 80 | 81 | return urls, nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/repository/user_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/domain/entity" 5 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type UserRepository interface { 10 | Create(user *entity.User) error 11 | FindByID(id uint) (*entity.User, error) 12 | FindByEmail(email string) (*entity.User, error) 13 | Update(user *entity.User) error 14 | ListAllUsers() ([]entity.User, error) 15 | GetByID(id uint) (*entity.User, error) 16 | Delete(id uint) error 17 | SetTrueMailConfirmationStatus(id uint) error 18 | } 19 | 20 | type userRepo struct { 21 | db *gorm.DB 22 | } 23 | 24 | func NewUserRepository() UserRepository { 25 | return &userRepo{ 26 | db: db.DB, 27 | } 28 | } 29 | 30 | // Create inserts new user 31 | func (r *userRepo) Create(user *entity.User) error { 32 | return r.db.Create(user).Error 33 | } 34 | 35 | // FindByID retrieves user by ID 36 | func (r *userRepo) FindByID(id uint) (*entity.User, error) { 37 | var user entity.User 38 | err := r.db.First(&user, id).Error 39 | return &user, err 40 | } 41 | 42 | // FindByEmail retrieves user by username 43 | func (r *userRepo) FindByEmail(email string) (*entity.User, error) { 44 | var user entity.User 45 | 46 | err := r.db.Where("email = ?", email).First(&user).Error 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | return &user, nil 52 | } 53 | 54 | // Update modifies to existing user 55 | func (r *userRepo) Update(user *entity.User) error { 56 | return r.db.Save(user).Error 57 | } 58 | 59 | // ListAllUsers retrieves all user records 60 | func (r *userRepo) ListAllUsers() ([]entity.User, error) { 61 | var users []entity.User 62 | 63 | err := r.db.Find(&users).Error 64 | 65 | return users, err 66 | } 67 | 68 | // GetByID fund user record with id parameter 69 | func (r *userRepo) GetByID(id uint) (*entity.User, error) { 70 | var user entity.User 71 | err := r.db.First(&user, id).Error 72 | return &user, err 73 | } 74 | 75 | // Delete removes user record from database 76 | func (r *userRepo) Delete(id uint) error { 77 | if err := r.db.Where("id = ?", id).Delete(&entity.User{}).Error; err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | 83 | // SetTrueMailConfirmationStatus set true is_mail_confirmed field in selected user record with userID 84 | func (r *userRepo) SetTrueMailConfirmationStatus(id uint) error { 85 | if err := r.db.Model(&entity.User{}).Where("id=?", id).Updates(entity.User{IsMailConfirmed: true}).Error; err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/seed/admin.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/config" 5 | "github.com/CemAkan/url-shortener/internal/domain/entity" 6 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 7 | "github.com/CemAkan/url-shortener/pkg/logger" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | func SeedAdminUser() { 12 | adminMail := config.GetEnv("ADMIN_EMAIL", "") 13 | adminPass := config.GetEnv("ADMIN_PASSWORD", "") 14 | 15 | if adminPass == "" || adminMail == "" { 16 | logger.Log.Infof("Env not set, skipping admin seeding.") 17 | return 18 | } 19 | var exists bool 20 | err := db.DB.Model(&entity.User{}).Select("count(*) > 0").Where("is_admin = ?", true).Find(&exists).Error 21 | if err != nil { 22 | logger.Log.WithError(err).Error("Failed to check admin user") 23 | return 24 | } 25 | if exists { 26 | logger.Log.Info("Admin user already exists. Skipping seeding.") 27 | return 28 | } 29 | 30 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) 31 | if err != nil { 32 | logger.Log.Fatalf("Failed to hash password: %v", err) 33 | } 34 | 35 | admin := entity.User{ 36 | Name: "initial", 37 | Surname: "admin", 38 | Email: adminMail, 39 | Password: string(hashedPassword), 40 | IsAdmin: true, 41 | IsMailConfirmed: true, 42 | } 43 | 44 | if err := db.DB.Create(&admin).Error; err != nil { 45 | logger.Log.Fatalf("Failed to create admin user: %v", err) 46 | } 47 | 48 | logger.Log.Infof("Admin user seeded successfully.") 49 | } 50 | -------------------------------------------------------------------------------- /internal/service/click_flusher_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/CemAkan/url-shortener/internal/repository" 6 | "github.com/CemAkan/url-shortener/pkg/logger" 7 | "github.com/CemAkan/url-shortener/pkg/utils" 8 | ) 9 | 10 | var ( 11 | logFileName = "flusher" 12 | logFileOutputType = "file" 13 | ) 14 | 15 | type ClickFlusherService struct { 16 | repo repository.URLRepository 17 | } 18 | 19 | func NewClickFlusherService(repo repository.URLRepository) *ClickFlusherService { 20 | return &ClickFlusherService{ 21 | repo: repo, 22 | } 23 | } 24 | 25 | func (s *ClickFlusherService) FlushClicks() { 26 | ctx := context.Background() 27 | keys, err := utils.GetAllClickKeys(ctx) 28 | 29 | if err != nil { 30 | logger.Log.WithError(err).Error("Failed to get click keys from Redis") 31 | return 32 | } 33 | 34 | for _, key := range keys { 35 | code := key[len("clicks:"):] //get after 7 char 36 | 37 | count, err := utils.GetDailyClickCount(ctx, code) 38 | if err != nil { 39 | logger.Log.WithError(err).Warnf("Failed to get count for %s", key) 40 | continue 41 | } 42 | 43 | if err := s.repo.AddToTotalClicks(code, count); err != nil { 44 | logger.Log.WithError(err).Errorf("Failed to update DB clicks for %s", code) 45 | continue 46 | } 47 | 48 | if err := utils.DeleteClickKey(ctx, code); err != nil { 49 | logger.Log.WithError(err).Warnf("Failed to delete Redis key %s", key) 50 | continue 51 | } 52 | 53 | logger.SpecialLogger(logFileName, logFileOutputType).Infof("Flushed %d clicks for %s", count, code) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /internal/service/mail_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "github.com/CemAkan/url-shortener/config" 6 | "github.com/CemAkan/url-shortener/email" 7 | "github.com/CemAkan/url-shortener/internal/infrastructure/mail" 8 | "github.com/CemAkan/url-shortener/pkg/logger" 9 | "github.com/sirupsen/logrus" 10 | "time" 11 | ) 12 | 13 | var ( 14 | mailVerificationMailSubject string = "Please verify your email by clicking the button below." 15 | passwordResetMailSubject string = "Click the button below to reset your password. This link will expire in 15 minutes." 16 | mailLogger *logrus.Logger 17 | ) 18 | 19 | func init() { 20 | mailLogger = logger.SpecialLogger("mail", "file") 21 | } 22 | 23 | type MailService interface { 24 | SendVerificationMail(name, baseUrl, emailAddr, verifyLink string) error 25 | SendPasswordResetMail(name, baseUrl, emailAddr, verifyLink string) error 26 | VerifyLinkGenerator(userID uint, baseURL, subject string, duration time.Duration) (string, error) 27 | GetMailLogger() *logrus.Logger 28 | } 29 | 30 | type mailService struct{} 31 | 32 | // NewMailService constructs mailService struct 33 | func NewMailService() MailService { 34 | return &mailService{} 35 | } 36 | 37 | // SendVerificationMail renders verification template and sends email 38 | func (s *mailService) SendVerificationMail(name, baseUrl, emailAddr, verifyLink string) error { 39 | 40 | htmlBody, err := email.Render(email.EmailData{ 41 | Title: "Verify Your Email", 42 | Greeting: fmt.Sprintf("Hello %s,", name), 43 | Message: mailVerificationMailSubject, 44 | VerificationLink: verifyLink, 45 | LogoURL: baseUrl + "/api/assets/logo.png", 46 | HeaderURL: baseUrl + "/api/assets/header.png", 47 | ButtonText: "✔️ Verify Your Mail", 48 | }) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return mail.Mail.Send(emailAddr, "Verify Your Email", htmlBody) 54 | } 55 | 56 | // SendPasswordResetMail renders reset-password template and sends email 57 | func (s *mailService) SendPasswordResetMail(name, baseUrl, emailAddr, verifyLink string) error { 58 | htmlBody, err := email.Render(email.EmailData{ 59 | Title: "Reset Your Password", 60 | Greeting: fmt.Sprintf("Hello %s,", name), 61 | Message: passwordResetMailSubject, 62 | VerificationLink: verifyLink, 63 | LogoURL: baseUrl + "/api/assets/logo.png", 64 | HeaderURL: baseUrl + "/api/assets/header.png", 65 | ButtonText: "🔄 Reset Password", 66 | }) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return mail.Mail.Send(emailAddr, "Reset Your Password", htmlBody) 72 | } 73 | 74 | // VerifyLinkGenerator generates tokenized link for verification or password reset 75 | func (s *mailService) VerifyLinkGenerator(userID uint, baseURL, subject string, duration time.Duration) (string, error) { 76 | token, err := config.GenerateToken(userID, duration, subject) 77 | if err != nil { 78 | return "", err 79 | } 80 | 81 | return baseURL + "/" + token, nil 82 | } 83 | 84 | // GetMailLogger returns mail service logger 85 | func (s *mailService) GetMailLogger() *logrus.Logger { 86 | return mailLogger 87 | } 88 | -------------------------------------------------------------------------------- /internal/service/url_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/CemAkan/url-shortener/config" 7 | "github.com/CemAkan/url-shortener/internal/domain/entity" 8 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 9 | "github.com/CemAkan/url-shortener/internal/repository" 10 | "github.com/CemAkan/url-shortener/pkg/logger" 11 | "github.com/CemAkan/url-shortener/pkg/utils" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type URLService interface { 18 | Shorten(originalURL string, userID uint, customCode *string) (*entity.URL, error) 19 | GetUserURLs(userID uint) ([]entity.URL, error) 20 | GetSingleUrlRecord(code string) (*entity.URL, int, error) 21 | ResolveRedirect(ctx context.Context, code string) (string, error) 22 | UpdateUserURL(userID uint, oldCode string, newOriginalURL, newCode *string) error 23 | DeleteUserURL(userID uint, code string) error 24 | DeleteUserAllURLs(userID uint) error 25 | } 26 | 27 | type urlService struct { 28 | repo repository.URLRepository 29 | } 30 | 31 | func NewURLService(urlRepo repository.URLRepository) URLService { 32 | return &urlService{ 33 | repo: urlRepo, 34 | } 35 | } 36 | 37 | // Shorten redeclare url address 38 | func (s *urlService) Shorten(originalURL string, userID uint, customCode *string) (*entity.URL, error) { 39 | var code string 40 | 41 | if customCode != nil && *customCode != "" { 42 | 43 | code = strings.TrimSpace(*customCode) 44 | 45 | if utils.IsReservedCode(code) { 46 | return nil, errors.New("custom code is reserved and cannot be used") 47 | } 48 | 49 | isTaken := s.isCodeTaken(code) 50 | 51 | if isTaken { 52 | return nil, errors.New("custom code already taken") 53 | } 54 | } else { 55 | 56 | code = s.generateUniqueCode() 57 | 58 | if utils.IsReservedCode(code) { 59 | code = s.generateUniqueCode() 60 | } 61 | } 62 | 63 | url := &entity.URL{ 64 | Code: code, 65 | OriginalURL: originalURL, 66 | UserID: userID, 67 | } 68 | 69 | if err := s.repo.Create(url); err != nil { 70 | return nil, err 71 | } 72 | 73 | return url, nil 74 | } 75 | 76 | func (s *urlService) isCodeTaken(code string) bool { 77 | existing, _ := s.repo.FindByCode(code) 78 | 79 | if existing != nil && existing.ID != 0 { 80 | return true 81 | } 82 | 83 | return false 84 | } 85 | 86 | func (s *urlService) generateUniqueCode() string { 87 | for { 88 | code := utils.GenerateCode(7) 89 | existing, _ := s.repo.FindByCode(code) 90 | if existing == nil || existing.ID == 0 { 91 | return code 92 | } 93 | } 94 | } 95 | 96 | // GetUserURLs finds all userID related url records 97 | func (s *urlService) GetUserURLs(userID uint) ([]entity.URL, error) { 98 | return s.repo.FindByUserID(userID) 99 | 100 | } 101 | 102 | // GetSingleUrlRecord find url record with its daily click rate 103 | func (s *urlService) GetSingleUrlRecord(code string) (*entity.URL, int, error) { 104 | url, err := s.repo.FindByCode(code) 105 | if err != nil || url == nil { 106 | return nil, 0, err 107 | } 108 | 109 | // getting daily click rate from redis 110 | clickKey := "clicks:" + code 111 | dailyClicks, _ := cache.Redis.Get(context.Background(), clickKey).Int() 112 | 113 | return url, dailyClicks, nil 114 | } 115 | 116 | // ResolveRedirect translates given short code to original code with cache-db mechanism 117 | func (s *urlService) ResolveRedirect(ctx context.Context, code string) (string, error) { 118 | 119 | //look at redis to find cache record 120 | cacheKey := "code_cache:" + code 121 | if originalURL, err := cache.Redis.Get(ctx, cacheKey).Result(); err == nil && originalURL != "" { 122 | return originalURL, nil 123 | } 124 | 125 | //get daily click 126 | dailyClicks, _ := utils.GetDailyClickCount(ctx, code) 127 | 128 | //look at db to find original record 129 | url, err := s.repo.FindByCode(code) 130 | if err != nil || url == nil { 131 | return "", errors.New("not found") 132 | } 133 | 134 | //getting threshold from .env and transfer it to integer 135 | thresholdENVString := config.GetEnv("DAILY_CLICK_CACHE_THRESHOLD", "100") 136 | threshold, _ := strconv.Atoi(thresholdENVString) 137 | 138 | // db -> redis resolve redirect mechanism for hot links 139 | if dailyClicks >= threshold { 140 | if err := cache.Redis.Set(ctx, cacheKey, url.OriginalURL, 24*time.Hour).Err(); err != nil { 141 | logger.Log.Printf("Redis cache save error: %v", err.Error()) 142 | } 143 | 144 | } 145 | 146 | return url.OriginalURL, nil 147 | 148 | } 149 | 150 | // UpdateUserURL updates specific code record values 151 | func (s *urlService) UpdateUserURL(userID uint, oldCode string, newOriginalURL, newCode *string) error { 152 | 153 | url, err := s.repo.FindByCode(oldCode) 154 | if err != nil { 155 | return errors.New("url not found") 156 | } 157 | 158 | if url.UserID != userID { 159 | return errors.New("unauthorized") 160 | } 161 | 162 | // code update check 163 | if newCode != nil && *newCode != "" && *newCode != url.Code { 164 | isTaken := s.isCodeTaken(*newCode) 165 | 166 | if isTaken { 167 | return errors.New("new custom code already taken") 168 | } 169 | url.Code = *newCode 170 | } 171 | 172 | //original url update check 173 | if newOriginalURL != nil && *newOriginalURL != "" && *newOriginalURL != url.OriginalURL { 174 | url.OriginalURL = *newOriginalURL 175 | } 176 | 177 | // redis cache clean 178 | utils.DeleteURLCache(oldCode) 179 | 180 | return s.repo.Update(url) 181 | } 182 | 183 | func (s *urlService) DeleteUserURL(userID uint, code string) error { 184 | url, err := s.repo.FindByCode(code) 185 | if err != nil { 186 | return errors.New("url not found") 187 | } 188 | 189 | if url.UserID != userID { 190 | return errors.New("unauthorized") 191 | } 192 | 193 | utils.DeleteURLCache(code) 194 | 195 | return s.repo.Delete(code) 196 | } 197 | 198 | // DeleteUserAllURLs removes user relational urls and their redis data 199 | func (s *urlService) DeleteUserAllURLs(userID uint) error { 200 | 201 | urls, err := s.repo.DeleteUserAllUrls(userID) 202 | 203 | if err != nil { 204 | return err 205 | } 206 | 207 | // Redis key cleanup 208 | for _, url := range urls { 209 | utils.DeleteURLCache(url.Code) 210 | } 211 | 212 | return nil 213 | 214 | } 215 | -------------------------------------------------------------------------------- /internal/service/user_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "github.com/CemAkan/url-shortener/internal/domain/entity" 6 | "github.com/CemAkan/url-shortener/internal/repository" 7 | "golang.org/x/crypto/bcrypt" 8 | "net/mail" 9 | "strings" 10 | "unicode" 11 | ) 12 | 13 | type UserService interface { 14 | Register(email, password, name, surname string) (*entity.User, error) 15 | Login(email, password string) (*entity.User, error) 16 | GetByID(id uint) (*entity.User, error) 17 | DeleteUser(id uint) error 18 | ListAllUsers() ([]entity.User, error) 19 | SetTrueEmailConfirmation(id uint) error 20 | PasswordUpdate(userID uint, newPassword string) error 21 | } 22 | 23 | type userService struct { 24 | repo repository.UserRepository 25 | } 26 | 27 | func NewUserService(userRepo repository.UserRepository) UserService { 28 | return &userService{ 29 | repo: userRepo, 30 | } 31 | } 32 | 33 | // Register checks email existence and save new user record to db with hashed password 34 | func (s *userService) Register(email, password, name, surname string) (*entity.User, error) { 35 | // email existence checking 36 | existing, _ := s.repo.FindByEmail(email) 37 | 38 | if existing != nil { 39 | return nil, errors.New("email already registered") 40 | } 41 | 42 | //password hashing 43 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 44 | 45 | if err != nil { 46 | return nil, errors.New("password hashing failure") 47 | } 48 | 49 | formatedName, err := s.format(name) 50 | if err != nil { 51 | return nil, errors.New("name required") 52 | } 53 | 54 | formatedSurname, err := s.format(surname) 55 | if err != nil { 56 | return nil, errors.New("surname required") 57 | } 58 | 59 | if _, err := mail.ParseAddress(email); err != nil { 60 | return nil, errors.New("invalid email address") 61 | } 62 | 63 | user := &entity.User{ 64 | Name: formatedName, 65 | Surname: formatedSurname, 66 | Email: email, 67 | Password: string(hashedPassword), 68 | } 69 | 70 | if err := s.repo.Create(user); err != nil { 71 | return nil, errors.New("user create failure") 72 | } 73 | 74 | return user, nil 75 | 76 | } 77 | 78 | // Login checks email existence and its related password's correctness 79 | func (s *userService) Login(email, password string) (*entity.User, error) { 80 | 81 | user, err := s.repo.FindByEmail(email) 82 | 83 | if err != nil { 84 | return nil, errors.New("invalid username or password") 85 | } 86 | 87 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { 88 | return nil, errors.New("invalid username or password") 89 | } 90 | 91 | return user, nil 92 | } 93 | 94 | // GetByID return user with its id 95 | func (s *userService) GetByID(id uint) (*entity.User, error) { 96 | return s.repo.FindByID(id) 97 | } 98 | 99 | // DeleteUser deletes user record 100 | func (s *userService) DeleteUser(id uint) error { 101 | exist, _ := s.GetByID(id) 102 | if exist == nil { 103 | return errors.New("user not exist") 104 | } 105 | 106 | err := s.repo.Delete(id) 107 | 108 | if err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // ListAllUsers gets all user records 116 | func (s *userService) ListAllUsers() ([]entity.User, error) { 117 | return s.repo.ListAllUsers() 118 | } 119 | 120 | // Format cleans spaces, lower-cases everything, then capitalises only the first rune. 121 | func (s *userService) format(word string) (string, error) { 122 | // remove spaces outside 123 | w := strings.ToLower(strings.TrimSpace(word)) 124 | 125 | // remove spaces inside 126 | w = strings.ReplaceAll(w, " ", "") 127 | 128 | //if it is empty return it 129 | if w == "" { 130 | return "", errors.New("empty input") 131 | } 132 | 133 | //make capital first char 134 | runes := []rune(w) 135 | runes[0] = unicode.ToUpper(runes[0]) 136 | return string(runes), nil 137 | } 138 | 139 | // SetTrueEmailConfirmation sets true is_email_confirmed field 140 | func (s *userService) SetTrueEmailConfirmation(id uint) error { 141 | return s.repo.SetTrueMailConfirmationStatus(id) 142 | } 143 | 144 | // PasswordUpdate change password with new one 145 | func (s *userService) PasswordUpdate(userID uint, newPassword string) error { 146 | user, err := s.GetByID(userID) 147 | 148 | if err != nil { 149 | return errors.New("user not found") 150 | } 151 | 152 | //password hashing 153 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) 154 | 155 | if err != nil { 156 | return errors.New("password hashing failure") 157 | } 158 | 159 | user.Password = string(hashedPassword) 160 | 161 | return s.repo.Update(user) 162 | } 163 | -------------------------------------------------------------------------------- /internal/system/shutdown.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 5 | "github.com/CemAkan/url-shortener/internal/infrastructure/db" 6 | "github.com/CemAkan/url-shortener/pkg/logger" 7 | "github.com/gofiber/fiber/v2" 8 | "time" 9 | ) 10 | 11 | func GracefulShutdown(app *fiber.App) { 12 | logger.Log.Infof("Starting graceful shutdown...") 13 | 14 | // Fiber shutdown 15 | if err := app.Shutdown(); err != nil { 16 | logger.Log.WithError(err).Error("Failed to shutdown Fiber gracefully") 17 | } else { 18 | logger.Log.Infof("Fiber shutdown completed") 19 | } 20 | 21 | // Redis shutdown 22 | if cache.Redis != nil { 23 | if err := cache.Redis.Close(); err != nil { 24 | logger.Log.WithError(err).Error("Failed to close Redis connection") 25 | } else { 26 | logger.Log.Infof("Redis connection closed successfully") 27 | } 28 | } 29 | 30 | // DB shutdown 31 | sqlDB, err := db.DB.DB() 32 | if err == nil { 33 | if err := sqlDB.Close(); err != nil { 34 | logger.Log.WithError(err).Error("Failed to close DB pool") 35 | } else { 36 | logger.Log.Infof("DB pool closed successfully") 37 | } 38 | } else { 39 | logger.Log.WithError(err).Error("Failed to retrieve DB pool handle") 40 | } 41 | 42 | logger.Log.Infof("Application shutdown complete. Exiting.") 43 | 44 | //wait to all closings 45 | logger.Log.Infof("--- Program will close in 5 seconds ---") 46 | time.Sleep(time.Second * 5) 47 | } 48 | -------------------------------------------------------------------------------- /internal/system/signal.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "context" 5 | "github.com/CemAkan/url-shortener/pkg/logger" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | ) 10 | 11 | func HandleSignals(cancelFunc context.CancelFunc) { 12 | sigs := make(chan os.Signal, 1) 13 | signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) 14 | 15 | sig := <-sigs 16 | logger.Log.Infof("Received signal: %s", sig) 17 | 18 | cancelFunc() 19 | } 20 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "github.com/sirupsen/logrus" 6 | "io" 7 | "os" 8 | "time" 9 | ) 10 | 11 | var Log = logrus.New() 12 | var MainLogFile io.Writer 13 | 14 | func InitLogger() { 15 | 16 | //logs folder checker & creator 17 | if _, err := os.Stat("logs"); os.IsNotExist(err) { 18 | _ = os.Mkdir("logs", 0775) 19 | } 20 | 21 | var err error 22 | 23 | // app.log file opener and creator if it not exist 24 | MainLogFile, err = FileOpener("app") 25 | 26 | if err != nil { 27 | Log.Warn("failed to open log file, defaulting open stdout only") 28 | Log.SetOutput(os.Stdout) 29 | } else { 30 | Log.SetOutput(io.MultiWriter(MainLogFile, os.Stdout)) 31 | } 32 | 33 | Log.SetLevel(logrus.InfoLevel) 34 | 35 | //formatter 36 | Log.SetFormatter(&logrus.TextFormatter{ 37 | FullTimestamp: true, 38 | TimestampFormat: time.RFC3339, 39 | ForceColors: false, 40 | }) 41 | 42 | Log.SetLevel(logrus.InfoLevel) 43 | 44 | //formatter 45 | Log.SetFormatter(&logrus.TextFormatter{ 46 | FullTimestamp: true, 47 | TimestampFormat: time.RFC3339, 48 | ForceColors: false, 49 | }) 50 | 51 | } 52 | 53 | // SpecialLogger creates special *logrus.Logger objects with fileName and outputType (file|stdout) parameters 54 | func SpecialLogger(fileName string, outputType string) *logrus.Logger { 55 | logger := logrus.New() 56 | var output io.Writer 57 | 58 | switch outputType { 59 | case "file": 60 | // Use mainLogFile as fallback 61 | output = MainLogFile 62 | 63 | // If fileName is given, try to open that file 64 | if fileName != "" { 65 | file, err := FileOpener(fileName) 66 | if err != nil { 67 | Log.WithError(err).Warnf("Failed to open %s, using default main log file", fileName) 68 | } else { 69 | output = file 70 | } 71 | } 72 | 73 | case "stdout": 74 | output = os.Stdout 75 | 76 | default: 77 | Log.Warnf("Unknown outputType '%s', falling back to mainLogFile", outputType) 78 | output = Log.Out 79 | } 80 | 81 | // Configure logger 82 | logger.SetOutput(output) 83 | logger.SetLevel(logrus.InfoLevel) 84 | logger.SetFormatter(&logrus.TextFormatter{ 85 | FullTimestamp: true, 86 | TimestampFormat: time.RFC3339, 87 | ForceColors: false, 88 | }) 89 | 90 | return logger 91 | } 92 | 93 | // FileOpener open if file is exist or not create a new one 94 | func FileOpener(fileName string) (io.Writer, error) { 95 | logFilePath := fmt.Sprintf("logs/" + fileName + ".log") 96 | file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) 97 | 98 | if err != nil { 99 | return nil, err 100 | } 101 | return file, nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/utils/generator.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 9 | 10 | func GenerateCode(n int) string { 11 | rand.Seed(time.Now().UnixNano()) 12 | 13 | b := make([]rune, n) 14 | 15 | for i := range b { 16 | b[i] = letters[rand.Intn(len(letters))] 17 | } 18 | return string(b) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/utils/ip.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | // GetClientIP extracts the real IP from headers or falls back to c.IP() 11 | func GetClientIP(c *fiber.Ctx) string { 12 | if xff := c.Get("X-Forwarded-For"); xff != "" { 13 | // May contain multiple comma-separated IPs 14 | parts := strings.Split(xff, ",") 15 | ip := strings.TrimSpace(parts[0]) 16 | return stripPort(ip) 17 | } 18 | 19 | // If no proxy headers, fallback to real IP 20 | if xRealIP := c.Get("X-Real-IP"); xRealIP != "" { 21 | return strings.TrimSpace(xRealIP) 22 | } 23 | 24 | return stripPort(c.IP()) 25 | } 26 | 27 | func stripPort(ip string) string { 28 | //port cut 29 | if strings.Contains(ip, ":") { 30 | if h, _, err := net.SplitHostPort(ip); err == nil { 31 | return h 32 | } 33 | } 34 | // IPv6 zone ident %xxx cut 35 | if i := strings.Index(ip, "%"); i != -1 { 36 | return ip[:i] 37 | } 38 | return ip 39 | } 40 | -------------------------------------------------------------------------------- /pkg/utils/redis_click.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 7 | ) 8 | 9 | // TrackClick increments short url redis click counter 10 | func TrackClick(ctx context.Context, code string) { 11 | key := fmt.Sprintf("clicks:%s", code) 12 | 13 | pipe := cache.Redis.TxPipeline() 14 | 15 | // increment 16 | pipe.Incr(ctx, key) 17 | 18 | _, _ = pipe.Exec(ctx) 19 | } 20 | 21 | // GetDailyClickCount gets url click counter from redis 22 | func GetDailyClickCount(ctx context.Context, code string) (int, error) { 23 | key := fmt.Sprintf("clicks:%s", code) 24 | return cache.Redis.Get(ctx, key).Int() 25 | } 26 | 27 | // GetAllClickKeys gets all urls click counter records from redis 28 | func GetAllClickKeys(ctx context.Context) ([]string, error) { 29 | return cache.Redis.Keys(ctx, "clicks:*").Result() 30 | } 31 | 32 | // DeleteClickKey deletes click record 33 | func DeleteClickKey(ctx context.Context, code string) error { 34 | key := fmt.Sprintf("clicks:%s", code) 35 | return cache.Redis.Del(ctx, key).Err() 36 | } 37 | -------------------------------------------------------------------------------- /pkg/utils/redis_helper.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/CemAkan/url-shortener/internal/infrastructure/cache" 7 | "github.com/CemAkan/url-shortener/pkg/logger" 8 | ) 9 | 10 | func DeleteURLCache(code string) { 11 | keys := []string{ 12 | fmt.Sprintf("clicks:%s", code), 13 | fmt.Sprintf("hotlink:%s", code), 14 | } 15 | 16 | for _, key := range keys { 17 | if err := cache.Redis.Del(context.Background(), key).Err(); err != nil { 18 | logger.Log.Warnf("Failed to delete Redis key %s: %v", key, err) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/utils/reserved.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | var ReservedCodes = []string{ 6 | "api", "api/", "metrics", "health", 7 | "register", "login", "me", "shorten", 8 | "my", "admin", "verify", "docs", "assets", 9 | } 10 | 11 | func IsReservedCode(code string) bool { 12 | code = strings.ToLower(code) 13 | 14 | for _, reserved := range ReservedCodes { 15 | if code == reserved || strings.HasPrefix(code, reserved+"/") { 16 | return true 17 | } 18 | } 19 | return false 20 | } 21 | -------------------------------------------------------------------------------- /prometheus/alertmanager/alertmanager.tmpl.yml: -------------------------------------------------------------------------------- 1 | global: 2 | 3 | resolve_timeout: 5m 4 | 5 | smtp_smarthost: '${SMTP_HOST}:${SMTP_PORT}' 6 | smtp_from: '${SMTP_FROM}' 7 | smtp_auth_username: '${SMTP_USER}' 8 | smtp_auth_password: '${SMTP_PASS}' 9 | 10 | route: 11 | receiver: 'team-email' 12 | 13 | group_wait: 30s 14 | 15 | group_interval: 5m 16 | 17 | repeat_interval: 3h 18 | 19 | receivers: 20 | - name: 'team-email' 21 | email_configs: 22 | - to: '${TEAM_EMAIL}' 23 | send_resolved: true -------------------------------------------------------------------------------- /prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | 5 | alerting: 6 | alertmanagers: 7 | - static_configs: 8 | - targets: ['alertmanager:9093'] 9 | scheme: http 10 | path_prefix: /alert 11 | 12 | rule_files: 13 | - /etc/prometheus/rules.yml 14 | 15 | scrape_configs: 16 | 17 | - job_name: 'node_exporter' 18 | static_configs: 19 | - targets: 20 | - 'node_exporter:9100' 21 | 22 | - job_name: 'postgres_exporter' 23 | static_configs: 24 | - targets: 25 | - 'postgres_exporter:9187' 26 | 27 | - job_name: 'url_shortener' 28 | metrics_path: '/metrics' 29 | static_configs: 30 | - targets: 31 | - 'app:3000' -------------------------------------------------------------------------------- /prometheus/rules.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: system_metrics 3 | interval: 15s 4 | rules: 5 | - alert: HighCPUUsage 6 | expr: 100 - (avg by (instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 7 | for: 5m 8 | labels: 9 | severity: critical 10 | annotations: 11 | summary: "CPU > 80% for 5 minutes" 12 | description: "Instance {{ $labels.instance }} CPU usage is {{ printf \"%.0f\" $value }}%" 13 | 14 | - alert: HighMemoryUsage 15 | expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes > 0.85 16 | for: 5m 17 | labels: 18 | severity: warning 19 | annotations: 20 | summary: "RAM > 85% for 5 minutes" 21 | description: "Instance {{ $labels.instance }} memory usage is {{ humanizePercentage $value }}" 22 | 23 | - name: application_metrics 24 | interval: 1m 25 | rules: 26 | - alert: HighRedirectLatency 27 | expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="url-shortener"}[5m])) by (le)) > 1 28 | for: 3m 29 | labels: 30 | severity: critical 31 | annotations: 32 | summary: "95th percentile request latency > 1s" 33 | 34 | - alert: LowRequestRate 35 | expr: sum(rate(http_requests_total{job="url-shortener"}[60m])) < 100 36 | for: 60m 37 | labels: 38 | severity: warning 39 | annotations: 40 | summary: "Request rate < 100 req/s (60m)" 41 | 42 | - alert: HighRequestRate 43 | expr: sum(rate(http_requests_total{job="url-shortener"}[5m])) > 500 44 | for: 5m 45 | labels: 46 | severity: warning 47 | annotations: 48 | summary: "Request rate > 500 req/s (5m)" 49 | 50 | - alert: RedisDown 51 | expr: redis_up == 0 52 | for: 1m 53 | labels: 54 | severity: critical 55 | annotations: 56 | summary: "Redis connection down" 57 | description: "redis_up metric is {{ $value }}, Redis may be unreachable on instance {{ $labels.instance }}" 58 | 59 | - alert: DatabaseDown 60 | expr: db_up == 0 61 | for: 1m 62 | labels: 63 | severity: critical 64 | annotations: 65 | summary: "Database connection down" 66 | description: "db_up metric is {{ $value }}, database may be unreachable on instance {{ $labels.instance }}" 67 | 68 | - alert: MailServiceDown 69 | expr: mail_up == 0 70 | for: 1m 71 | labels: 72 | severity: critical 73 | annotations: 74 | summary: "Mail service connection down" 75 | description: "mail_up metric is {{ $value }}, SMTP may be unreachable on instance {{ $labels.instance }}" --------------------------------------------------------------------------------