From 26afd5e9172614d1cb9540e036da965b2ab2bb2d Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sat, 13 Jun 2026 16:33:16 +0530 Subject: [PATCH 01/32] add plan --- TARGET.md | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 720 insertions(+) create mode 100644 TARGET.md diff --git a/TARGET.md b/TARGET.md new file mode 100644 index 0000000..23edb78 --- /dev/null +++ b/TARGET.md @@ -0,0 +1,720 @@ +# Bittuly — Production Distributed URL Shortener +## Development & Deployment Plan + +> **Deployment Target: Kubernetes (K8s)** +> Local → `kind` cluster → Managed K8s (DigitalOcean DOKS) +> +> **Why Kubernetes?** +> - Industry standard for production microservices at every major company +> - Manifests are 100% portable: DOKS today, EKS/GKE tomorrow, zero rewrite +> - Forces you to solve real distributed problems: health probes, rolling updates, +> HPA scaling, PDB availability guarantees, secrets management +> - The single most valuable infrastructure skill in backend engineering today +> +> **Why DigitalOcean DOKS?** +> - Managed control plane (free), you only pay for worker nodes +> - ~$72/month for a 3-node cluster (2 vCPU, 4 GB each) — cheapest managed K8s +> - Simple kubectl integration, no AWS/GCP account complexity needed to start + +--- + +## 1. Goal + +Build and deploy a **production-grade, horizontally scalable URL shortener** using a +microservices architecture. The system must handle: + +- **10,000 redirects / second** at p99 < 15 ms (cache hit) / < 60 ms (cache miss) +- **1,000 shortening requests / minute** across the cluster +- **99.9 % availability** (≤ 8.7 hours downtime / year) +- **Zero-downtime deploys** via Kubernetes rolling updates +- **Automatic recovery** from single-node or single-pod failures + +--- + +## 2. Architecture Overview + +``` +Internet (HTTPS only) + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ NGINX Ingress Controller │ +│ • TLS termination (cert-manager + Let's Encrypt auto-renew) │ +│ • Rate limiting (per-IP, per-route annotations) │ +│ • JWT validation (auth_request to auth-service) │ +│ • Routes /api/auth/** → auth-service │ +│ /api/** → url-service │ +│ /** → frontend (Nginx static) │ +└──────────────────────┬─────────────────────┬──────────────────┘ + (K8s ClusterIP) (K8s ClusterIP) + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────────┐ + │ auth-service │ │ url-service │ + │ Rust + Axum │ │ Rust + Axum │ + │ min 2 pods │ │ min 3 pods │ + │ HPA → max 5 │ │ HPA → max 10 │ + └────────┬─────────┘ └──────────┬────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ + │ PgBouncer │ │ PgBouncer │ + │ (pooler) │ │ (pooler) │ + └──────┬───────┘ └────────┬──────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ + │ pg-auth │ │ pg-urls │ + │ Postgres 17 │ │ Postgres 17 │ + │ users, otps │ │ urls │ + └──────────────┘ └──────────────────┘ + + ┌────────────────────────┐ ┌──────────────────────────┐ + │ RabbitMQ (3-node) │ │ Redis Sentinel │ + │ user.events exchange │ │ 1 master + 2 replicas │ + │ url.events exchange │ │ cache + rate-limit store │ + └────────────────────────┘ └──────────────────────────┘ + + ┌────────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ + │ Prometheus │ │ Grafana │ │ Loki │ │ Jaeger │ + │ (metrics) │ │(dashbrd) │ │ (logs) │ │(traces)│ + └────────────┘ └──────────┘ └────────┘ └────────┘ +``` + +--- + +## 3. Services + +### 3.1 auth-service (port 3001, internal only) + +| Property | Value | +|---|---| +| Database | `pg-auth` — tables: `users`, `otps` | +| Replicas | min 2, max 5 (HPA on CPU > 70%) | +| CPU | 100m request / 500m limit | +| Memory | 64 Mi request / 256 Mi limit | + +**Owns:** +- `POST /api/auth/signup` +- `POST /api/auth/login` +- `POST /api/auth/verify-otp` +- `DELETE /api/auth/account` → publishes `user.deleted` to RabbitMQ +- JWT issuance (HS256, configurable TTL) +- `GET /api/auth/validate` — lightweight endpoint called by NGINX auth_request + +**Does NOT own:** anything about URLs, clicks, or Redis caching. + +--- + +### 3.2 url-service (port 3002, internal only) + +| Property | Value | +|---|---| +| Database | `pg-urls` — table: `urls` | +| Cache | Redis (cache-aside, LRU eviction) | +| Replicas | min 3, max 10 (HPA on CPU > 60%) | +| CPU | 200m request / 1000m limit | +| Memory | 128 Mi request / 512 Mi limit | + +**Owns:** +- `GET /:short_code` — redirect (public) +- `POST /api/urls` — shorten (authenticated via X-User-Id header) +- `GET /api/urls` — list with cursor pagination + search +- `DELETE /api/urls/:id` — delete +- Batch click consumer (tokio::select!, size=17 OR 30s interval) +- RabbitMQ consumer: `user.deleted` → delete user's URLs + evict Redis keys +- URL expiration check on redirect (future Phase 3) + +**Does NOT own:** user credentials, JWT issuance. + +--- + +### 3.3 NGINX Ingress Controller (API Gateway) + +Not a custom Rust service. Battle-tested, Kubernetes-native, maintained by the community. + +| Feature | Mechanism | +|---|---| +| TLS | cert-manager + Let's Encrypt, auto-renewed | +| Rate limiting | `nginx.ingress.kubernetes.io/limit-rps` per route | +| JWT check | `auth-url` annotation → `auth-service /api/auth/validate` | +| CORS | Annotation-based | +| Secure headers | ConfigMap server-snippet (HSTS, X-Frame-Options, CSP) | + +--- + +### 3.4 libs/shared (Cargo crate, compiled into both services) + +``` +libs/shared/src/ + config.rs ← Settings struct, from_env() + errors.rs ← AppError enum implementing IntoResponse + jwt.rs ← Claims struct, decode/encode helpers + middleware.rs ← extract_user_id middleware (reads X-User-Id header) + telemetry.rs ← OpenTelemetry + tracing subscriber init + lib.rs +``` + +--- + +## 4. Technology Stack + +| Component | Technology | Why | +|---|---|---| +| Language | Rust + Axum | Memory safe, zero-cost abstractions, best-in-class latency | +| Primary DB | PostgreSQL 17 | ACID, pg_trgm for search, battle-tested | +| Connection pooler | PgBouncer | Reduces Postgres connection overhead at scale | +| Cache | Redis + Sentinel | HA cache without Redis Cluster overhead | +| Message broker | RabbitMQ 3 (3-node) | AMQP standard, durable queues, fan-out exchanges, DLQ | +| Rust AMQP client | lapin + deadpool-lapin | Async, tokio-native, maintained | +| Ingress / Gateway | NGINX Ingress Controller | K8s-native, proven at scale, rich annotation API | +| TLS | cert-manager + Let's Encrypt | Automated issuance and renewal | +| Orchestration | Kubernetes | Industry standard, portable across all clouds | +| Local K8s | kind | Matches production API exactly, lightweight | +| Package manager | Helm 3 | Templated K8s manifests, release history, rollback | +| Metrics | Prometheus + Grafana | Pull-based, rich query language, standard dashboards | +| Log aggregation | Loki + Promtail | K8s-native, integrates with Grafana | +| Distributed tracing | OpenTelemetry → Jaeger | Vendor-neutral, correlate traces across services | +| CI pipeline | GitHub Actions | Integrated with repo, free for public repos | +| CD / GitOps | ArgoCD | Declarative, self-healing, rollback = git revert | +| Container registry | GHCR (GitHub Packages) | Free, integrated with GHA | +| Secrets | Kubernetes Secrets + Sealed Secrets | Encrypted at rest, safe to commit encrypted form | +| URL safety | Google Safe Browsing API v4 | Block phishing/malware before shortening | + +--- + +## 5. Workspace File Structure + +``` +bittuly/ +├── Cargo.toml ← workspace root +├── Cargo.lock +│ +├── services/ +│ ├── auth-service/ +│ │ ├── Cargo.toml +│ │ ├── Dockerfile +│ │ └── src/ +│ │ ├── main.rs +│ │ ├── handlers/ ← signup, login, verify_otp, delete_account, validate +│ │ ├── repository/ ← user_repo, otp_repo +│ │ ├── services/ +│ │ └── events/ ← rabbitmq publisher (user.deleted) +│ │ +│ └── url-service/ +│ ├── Cargo.toml +│ ├── Dockerfile +│ └── src/ +│ ├── main.rs +│ ├── handlers/ ← shorten, redirect, list, delete +│ ├── repository/ ← url_repo +│ ├── services/ +│ ├── cache/ ← redis cache-aside logic +│ ├── consumer/ ← click batch consumer (tokio::select!) +│ └── events/ ← rabbitmq subscriber (user.deleted) +│ +├── libs/ +│ └── shared/ +│ ├── Cargo.toml +│ └── src/ +│ ├── config.rs +│ ├── errors.rs +│ ├── jwt.rs +│ ├── middleware.rs +│ ├── telemetry.rs +│ └── lib.rs +│ +├── web/ ← React frontend (unchanged) +│ +├── helm/ +│ ├── auth-service/ ← Helm chart +│ ├── url-service/ ← Helm chart +│ ├── rabbitmq/ ← Helm chart (Bitnami) +│ ├── redis/ ← Helm chart (Bitnami + Sentinel) +│ └── monitoring/ ← Prometheus, Grafana, Loki, Jaeger +│ +├── k8s/ +│ ├── base/ ← Namespace, RBAC, NetworkPolicy +│ └── overlays/ +│ ├── local/ ← kind cluster (self-signed TLS, low replicas) +│ └── production/ ← DOKS cluster (Let's Encrypt, full replicas) +│ +├── docker/ +│ ├── postgres-auth/init/ ← schema for pg-auth +│ └── postgres-urls/init/ ← schema for pg-urls +│ +├── .github/ +│ └── workflows/ +│ ├── ci.yml ← PR: fmt + clippy + audit + test + build +│ └── cd.yml ← main: build images + push + ArgoCD sync +│ +├── docker-compose.yml ← local development only +├── TARGET.md ← this file +└── README.md +``` + +--- + +## 6. Database Schemas + +### pg-auth + +```sql +CREATE TABLE users ( + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- bcrypt + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE otps ( + otp_id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + code VARCHAR(6) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE +); +``` + +### pg-urls + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE urls ( + url_id BIGSERIAL PRIMARY KEY, + short_code VARCHAR(12) UNIQUE, + original_url TEXT NOT NULL, + user_id UUID NOT NULL, -- denormalized, no FK (cross-DB boundary) + click_count BIGINT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, -- NULL = never expires + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (original_url, user_id) +); + +CREATE INDEX idx_urls_user_id ON urls (user_id, url_id DESC); +CREATE INDEX idx_urls_short_code ON urls (short_code); +CREATE INDEX idx_urls_original_trgm ON urls USING GIN (original_url gin_trgm_ops); +CREATE INDEX idx_urls_expires_at ON urls (expires_at) WHERE expires_at IS NOT NULL; +``` + +> **Note on the missing foreign key:** +> In the monolith, `urls.user_id` referenced `users.user_id` via FK. +> In microservices, each service owns its own database — cross-DB foreign keys don't exist. +> Referential integrity is maintained via the RabbitMQ `user.deleted` event pattern. + +--- + +## 7. RabbitMQ Event Bus + +### Exchange topology + +``` +Exchange: user.events (type: direct, durable: true) + └── Binding: routing_key = user.deleted + └── Queue: q.url-service.user-deleted (durable, DLQ configured) + └── Dead-letter: q.dlq.user-deleted + +Exchange: url.events (type: topic, durable: true) + └── Binding: routing_key = url.* + └── Queue: (future consumers — analytics, notifications) +``` + +### Event payloads + +```json +// user.deleted — published by auth-service +{ + "event": "user.deleted", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2026-01-01T00:00:00Z" +} + +// url.expired — published by url-service background sweeper (Phase 3) +{ + "event": "url.expired", + "short_code": "aB3xY", + "user_id": "550e8400-...", + "timestamp": "2026-01-01T00:00:00Z" +} +``` + +### Reliability guarantees + +| Property | Value | +|---|---| +| Queue durability | `durable: true` — survive broker restart | +| Message persistence | `delivery_mode: 2` — written to disk | +| Consumer acknowledgement | Manual `ack` after successful processing | +| Retry on failure | Exponential backoff: 1s → 2s → 4s → 8s → DLQ | +| Dead-letter queue | Failed messages inspectable, replayable | + +--- + +## 8. Kubernetes Resources + +### Horizontal Pod Autoscaler — url-service +```yaml +minReplicas: 3 +maxReplicas: 10 +metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 60 +``` + +### Pod Disruption Budget — url-service +```yaml +# At least 2 pods always available during node drain / rolling update +spec: + minAvailable: 2 +``` + +### Health probes (both services) +```yaml +livenessProbe: + httpGet: { path: /health, port: 3002 } + initialDelaySeconds: 10 + periodSeconds: 15 + failureThreshold: 3 + +readinessProbe: + httpGet: { path: /health, port: 3002 } + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 2 # removed from load balancer after 2 failures +``` + +### Network Policy +```yaml +# url-service only accepts traffic from ingress controller +# url-service only talks to pg-urls and redis +# auth-service only talks to pg-auth and rabbitmq +# Nothing talks directly to databases except its owning service +``` + +--- + +## 9. Observability + +### Metrics (Prometheus scrape) +Each service exposes `GET /metrics` using the `metrics` + `metrics-exporter-prometheus` crates. + +| Metric | Type | Labels | +|---|---|---| +| `http_requests_total` | Counter | service, method, path, status_code | +| `http_request_duration_seconds` | Histogram | service, method, path | +| `redirect_cache_hits_total` | Counter | — | +| `redirect_cache_misses_total` | Counter | — | +| `click_batch_flush_total` | Counter | trigger (size / interval / shutdown) | +| `rabbitmq_published_total` | Counter | exchange, routing_key | +| `rabbitmq_consumed_total` | Counter | queue, result (ok / error) | +| `db_query_duration_seconds` | Histogram | service, query | +| `url_safety_checks_total` | Counter | result (safe / unsafe / error) | + +### Grafana Dashboards +1. **Service Overview** — RPS, error rate, p50/p95/p99 latency per service +2. **Redirect Performance** — cache hit rate, Redis latency, DB fallback rate +3. **Infrastructure** — pod CPU/memory, HPA events, node pressure +4. **Business Metrics** — URLs created/hour, redirects/hour, top short codes by clicks + +### Alerting Rules + +| Alert | Condition | Severity | +|---|---|---| +| High error rate | error_rate > 1% for 5 min | Critical | +| High latency | p99 redirect > 200ms for 5 min | Warning | +| Cache hit rate degraded | cache_hit_rate < 70% for 10 min | Warning | +| Pod crash looping | restarts > 3 in 10 min | Critical | +| RabbitMQ DLQ growing | dlq_depth > 50 | Warning | +| DB connection pool exhausted | pool_wait_count > 10 | Critical | + +### Distributed Tracing +- `tracing-opentelemetry` instruments every Axum handler automatically +- `traceparent` header propagated: Ingress → auth-service / url-service +- DB queries and Redis calls are child spans +- Jaeger UI for local; Tempo (Grafana Cloud) for production + +### Logging +- `tracing_subscriber` in JSON format in production (`RUST_LOG=info`) +- Promtail daemonset ships all pod logs to Loki +- Log retention: 30 days +- Every log line includes `trace_id`, `service`, `pod_name` + +--- + +## 10. Security + +| Control | Implementation | +|---|---| +| TLS | cert-manager + Let's Encrypt, HTTPS-only, HTTP → HTTPS redirect | +| JWT validation | NGINX `auth_request` → `auth-service /api/auth/validate` | +| Rate limiting — redirects | 100 req/s per IP (`limit-rps: "100"`) | +| Rate limiting — shorten | 10 req/min per user (`limit-rpm: "10"` + X-User-Id key) | +| URL safety | Google Safe Browsing API v4, checked before every INSERT | +| Secrets | Kubernetes Secrets + Sealed Secrets operator (safe to git-commit) | +| Network policy | Deny-all default, allow-list per service | +| Container hardening | `runAsNonRoot`, read-only root filesystem, `allowPrivilegeEscalation: false` | +| Dependency audit | `cargo audit` in CI on every PR | +| Secure HTTP headers | HSTS, X-Frame-Options: DENY, X-Content-Type-Options, CSP | +| No internal port exposure | Only Ingress LoadBalancer is publicly accessible | + +--- + +## 11. CI/CD Pipeline + +### CI — Every Pull Request +``` +1. cargo fmt --check +2. cargo clippy --workspace -- -D warnings +3. cargo audit +4. cargo test --workspace +5. docker build auth-service (smoke-test only) +6. docker build url-service (smoke-test only) +``` + +### CD — Every merge to `main` +``` +1. cargo test --workspace +2. docker build auth-service → push to GHCR with git SHA tag +3. docker build url-service → push to GHCR with git SHA tag +4. docker build frontend → push to GHCR with git SHA tag +5. Update helm/*/values.yaml image tags (automated commit) +6. ArgoCD detects diff → applies to K8s cluster +7. K8s rolls update with zero downtime (RollingUpdate strategy) +``` + +### Branch Strategy +- `main` — production-ready, protected, requires CI green + 1 review +- `dev` — integration branch for feature branches +- `feat/*` — individual features, short-lived +- Tags: `v1.0.0`, `v1.1.0` trigger GitHub Releases + +--- + +## 12. Development Phases + +### ✅ Completed (Monolith) +- User auth (signup, login, OTP, JWT) +- URL shortening (base62, BIGSERIAL, unique constraint) +- Redis cache-aside with LRU eviction +- Click batch consumer (size trigger + 30s interval flush) +- Cursor-based pagination with search (pg_trgm) +- GET /health endpoint +- System Health UI page +- Insights page (pie chart, bar chart, performance table) + +--- + +### Phase 0 — Workspace Restructure +**Goal:** Convert monolith repo into Cargo workspace. No functional change. + +- [ ] Create workspace `Cargo.toml` with `[workspace]` members +- [ ] Create `libs/shared/` crate — move JWT types, config, errors into it +- [ ] Create `services/auth-service/` skeleton — copy auth handlers/models/repos +- [ ] Create `services/url-service/` skeleton — copy url handlers/models/repos +- [ ] Both services compile independently with `shared` as a dependency +- [ ] `docker-compose.yml` builds both services, both start successfully +- [ ] All existing functionality works end-to-end + +--- + +### Phase 1 — Service Extraction & DB Split +**Goal:** Two fully independent services with separate databases. + +- [ ] `pg-auth`: users + otps tables only +- [ ] `pg-urls`: urls table only, `user_id` is a plain UUID column (no FK) +- [ ] `auth-service`: issues JWTs; validates via `/api/auth/validate` +- [ ] `url-service`: reads `X-User-Id` header injected by gateway (no JWT parsing) +- [ ] Docker Compose: add simple NGINX or Caddy as reverse proxy for local dev +- [ ] Frontend routes unchanged — still talks to `:3000` +- [ ] Full end-to-end test: signup → login → shorten → redirect → delete + +--- + +### Phase 2 — RabbitMQ Event Bus +**Goal:** Reliable cross-service event delivery with durability guarantees. + +- [ ] Add RabbitMQ to Docker Compose (management UI on :15672) +- [ ] Add `lapin` + `deadpool-lapin` to both services via shared crate +- [ ] `auth-service` publishes `user.deleted` on account deletion +- [ ] `url-service` consumes `user.deleted`: + - Deletes all URLs for that user_id + - Evicts their short_codes from Redis + - Manual ack after successful DB + Redis operations +- [ ] Dead-letter queue for failed messages +- [ ] Integration test: delete account → verify URLs gone, Redis keys evicted + +--- + +### Phase 3 — URL Expiration +**Goal:** URLs can have a TTL; expired URLs return 410 Gone. + +- [ ] Add `expires_at TIMESTAMPTZ` column to pg-urls (nullable) +- [ ] `POST /api/urls` body accepts optional `expires_at` RFC3339 timestamp +- [ ] Redirect handler: if `expires_at < NOW()` → 410 Gone (check DB, skip Redis) +- [ ] Redis TTL = `min(expires_at - now(), 24h)` instead of hardcoded 24h +- [ ] Background sweeper task: every 5 minutes, delete expired URLs, publish `url.expired` +- [ ] Frontend: date picker on shorten form; expired links shown with visual indicator + +--- + +### Phase 4 — URL Safety Check +**Goal:** Prevent shortening of phishing / malware URLs. + +- [ ] Google Safe Browsing API v4 integration (free, 10k queries/day) +- [ ] Called synchronously in `shorten_url` service, before DB write +- [ ] 422 Unprocessable Entity + `{ "error": "URL flagged as unsafe" }` if matched +- [ ] Fail-open: if Safe Browsing API is unreachable, log warning + allow request +- [ ] Frontend toast for unsafe URL rejection + +--- + +### Phase 5 — Metrics Instrumentation +**Goal:** Full Prometheus metrics from both services. + +- [ ] Add `metrics` + `metrics-exporter-prometheus` crates to both services via shared +- [ ] Instrument all HTTP handlers (counter + histogram) +- [ ] Instrument cache hit/miss, batch flush trigger, RabbitMQ publish/consume +- [ ] `GET /metrics` on both services (internal port only, not exposed via ingress) +- [ ] Add Prometheus + Grafana to Docker Compose for local development +- [ ] Build the 4 dashboards listed in Section 9 + +--- + +### Phase 6 — Distributed Tracing +**Goal:** End-to-end trace correlation across services. + +- [ ] Add `opentelemetry`, `tracing-opentelemetry`, `opentelemetry-otlp` to shared +- [ ] `init_telemetry()` in shared crate, called by both service `main.rs` +- [ ] Propagate `traceparent` W3C header through NGINX → services +- [ ] DB queries and Redis calls wrapped as child spans +- [ ] Jaeger in Docker Compose (UI on :16686) + +--- + +### Phase 7 — Kubernetes Manifests (kind cluster) +**Goal:** Full system runs on local Kubernetes, identical to production structure. + +- [ ] Install kind, create cluster config +- [ ] Helm chart: `auth-service` — Deployment, Service, HPA, PDB, Secret, ConfigMap +- [ ] Helm chart: `url-service` — Deployment, Service, HPA, PDB, Secret, ConfigMap +- [ ] Helm chart: RabbitMQ (Bitnami), 3-node cluster +- [ ] Helm chart: Redis (Bitnami), Sentinel mode +- [ ] Helm chart: PostgreSQL (Bitnami), two separate releases (auth + urls) +- [ ] Helm chart: monitoring stack (kube-prometheus-stack + Loki + Jaeger) +- [ ] NGINX Ingress Controller with rate-limiting annotations +- [ ] cert-manager with self-signed ClusterIssuer for local TLS +- [ ] ArgoCD installed, syncing from `helm/` directory +- [ ] NetworkPolicy: deny-all default, allow-list per service +- [ ] All health probes and PDB verified + +--- + +### Phase 8 — CI/CD Pipeline +**Goal:** Every PR is tested; every merge to main deploys automatically. + +- [ ] `.github/workflows/ci.yml`: fmt, clippy, audit, test, docker build +- [ ] `.github/workflows/cd.yml`: build images, push to GHCR, update Helm values, ArgoCD sync +- [ ] Branch protection on `main`: require CI green +- [ ] Semantic release: `git tag v1.x.x` triggers GitHub Release with changelog + +--- + +### Phase 9 — Production Deployment (DOKS) +**Goal:** Live on a real managed Kubernetes cluster with a real domain. + +- [ ] Create DigitalOcean account, provision DOKS cluster (3 × s-2vcpu-4gb) +- [ ] Configure `kubectl` with production context +- [ ] Install cert-manager + NGINX Ingress + ArgoCD on production cluster +- [ ] Configure DNS: `yourdomain.com` A record → LoadBalancer IP +- [ ] ArgoCD points to `k8s/overlays/production/` +- [ ] Deploy all Helm charts via ArgoCD +- [ ] Verify Let's Encrypt certificate issued (check cert-manager logs) +- [ ] Smoke test: shorten → redirect → Grafana shows metric +- [ ] Configure Grafana alerts → email / PagerDuty + +--- + +### Phase 10 — Load Testing & SLO Verification +**Goal:** Verify system meets SLOs under realistic load. + +- [ ] Write k6 load test scripts: redirect burst, shorten sustained, mixed +- [ ] Baseline: 1,000 RPS redirect endpoint, 5-minute sustained run +- [ ] Verify p99 latency < 15ms at peak (cache hit path) +- [ ] Verify HPA scales url-service from 3 → N pods under load +- [ ] Verify rolling deploy causes zero 5xx errors under sustained load +- [ ] Tune PgBouncer pool size, Redis connection pool, HPA thresholds based on results +- [ ] Document results in `docs/load-test-results.md` + +--- + +## 13. SLOs + +| Endpoint | p50 | p99 | Target availability | +|---|---|---|---| +| `GET /:code` (cache hit) | < 5 ms | < 15 ms | 99.9% | +| `GET /:code` (cache miss) | < 15 ms | < 60 ms | 99.9% | +| `POST /api/urls` | < 50 ms | < 200 ms | 99.5% | +| `POST /api/auth/login` | < 80 ms | < 400 ms | 99.5% | +| Overall system availability | — | — | 99.9% (monthly) | +| Redis cache hit rate | — | — | > 85% | + +--- + +## 14. Local Development Workflow + +```bash +# Start all services (Docker Compose) +docker compose up + +# Run a specific service with hot-reload (requires cargo-watch) +cargo watch -x 'run --bin auth-service' +cargo watch -x 'run --bin url-service' + +# Run all tests +cargo test --workspace + +# Run a specific service's tests +cargo test -p auth-service + +# Check everything compiles +cargo check --workspace + +# Lint +cargo clippy --workspace -- -D warnings + +# Security audit +cargo audit + +# View structured logs with pretty-printing +docker compose logs -f url-service | jq . + +# Access UIs +open http://localhost:15672 # RabbitMQ management (guest/guest) +open http://localhost:3003 # Grafana (admin/admin) +open http://localhost:16686 # Jaeger +``` + +--- + +## 15. Open Decisions + +| # | Decision | Options | Status | +|---|---|---|---| +| 1 | Cloud provider for production K8s | DOKS, EKS, GKE, Hetzner | **DOKS recommended** | +| 2 | Custom short codes | Phase 3 add-on | Deferred | +| 3 | Per-day click analytics | Requires schema change | Deferred | +| 4 | Frontend deployment | Nginx in K8s vs Vercel CDN | Nginx in K8s (simpler) | +| 5 | Rate limit storage | Redis annotations vs custom middleware | NGINX annotations | + +--- + +*Document created: 2026-06-13* +*Current status: Phase 0 — Workspace Restructure — NOT STARTED* From 3530e9ed569455a94a8359f40d2c1314c700cbeb Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 17:14:55 +0530 Subject: [PATCH 02/32] change structure according to microservice architecture requirements --- .gitignore | 1 + Cargo.lock | 116 ++++++---- Cargo.toml | 50 +++-- docker/postgres-auth/init/01-init.sql | 10 + .../init/01-init.sql} | 13 +- docker/postgres/init/02-add-click-count.sql | 3 - .../postgres/init/03-add-search-indexes.sql | 2 - services/url-service/Cargo.toml | 23 ++ .../url-service/src/handlers.rs | 10 +- services/url-service/src/main.rs | 9 + .../url-service/src/models.rs | 0 .../url-service/src/repository.rs | 19 +- .../url-service/src/routes.rs | 10 +- .../url-service/src/services.rs | 3 +- src/app/mod.rs | 1 - src/app/state.rs | 21 -- src/config/mod.rs | 1 - src/config/settings.rs | 34 --- src/db/mod.rs | 2 - src/db/postgres.rs | 10 - src/db/redis.rs | 22 -- src/handlers/debug_handler.rs | 8 - src/handlers/health_handler.rs | 76 ------- src/handlers/mod.rs | 4 - src/handlers/user_handler.rs | 186 ---------------- src/main.rs | 120 ---------- src/middlewares/jwt.rs | 206 ------------------ src/middlewares/mod.rs | 1 - src/models/mod.rs | 4 - src/models/url/mod.rs | 3 - src/models/user/entity.rs | 60 ----- src/models/user/mod.rs | 8 - src/repository/mod.rs | 2 - src/repository/user_repository.rs | 70 ------ src/routes/mod.rs | 3 - src/routes/router.rs | 52 ----- src/routes/user_routes.rs | 26 --- src/services/mod.rs | 2 - src/services/user_service.rs | 148 ------------- src/utils/email.rs | 44 ---- src/utils/mod.rs | 2 - src/utils/otp_store.rs | 49 ----- tests/url_service.rs | 2 - 43 files changed, 165 insertions(+), 1271 deletions(-) create mode 100644 docker/postgres-auth/init/01-init.sql rename docker/{postgres/init/01-init-schema.sql => postgres-urls/init/01-init.sql} (58%) delete mode 100644 docker/postgres/init/02-add-click-count.sql delete mode 100644 docker/postgres/init/03-add-search-indexes.sql create mode 100644 services/url-service/Cargo.toml rename src/handlers/url_handler.rs => services/url-service/src/handlers.rs (97%) create mode 100644 services/url-service/src/main.rs rename src/models/url/entity.rs => services/url-service/src/models.rs (100%) rename src/repository/url_repository.rs => services/url-service/src/repository.rs (93%) rename src/routes/url_routes.rs => services/url-service/src/routes.rs (64%) rename src/services/url_service.rs => services/url-service/src/services.rs (91%) delete mode 100644 src/app/mod.rs delete mode 100644 src/app/state.rs delete mode 100644 src/config/mod.rs delete mode 100644 src/config/settings.rs delete mode 100644 src/db/mod.rs delete mode 100644 src/db/postgres.rs delete mode 100644 src/db/redis.rs delete mode 100644 src/handlers/debug_handler.rs delete mode 100644 src/handlers/health_handler.rs delete mode 100644 src/handlers/mod.rs delete mode 100644 src/handlers/user_handler.rs delete mode 100644 src/main.rs delete mode 100644 src/middlewares/jwt.rs delete mode 100644 src/middlewares/mod.rs delete mode 100644 src/models/mod.rs delete mode 100644 src/models/url/mod.rs delete mode 100644 src/models/user/entity.rs delete mode 100644 src/models/user/mod.rs delete mode 100644 src/repository/mod.rs delete mode 100644 src/repository/user_repository.rs delete mode 100644 src/routes/mod.rs delete mode 100644 src/routes/router.rs delete mode 100644 src/routes/user_routes.rs delete mode 100644 src/services/mod.rs delete mode 100644 src/services/user_service.rs delete mode 100644 src/utils/email.rs delete mode 100644 src/utils/mod.rs delete mode 100644 src/utils/otp_store.rs delete mode 100644 tests/url_service.rs diff --git a/.gitignore b/.gitignore index ba481b1..9e1d5de 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ target /target .env +/tmp diff --git a/Cargo.lock b/Cargo.lock index 576fc49..8becee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,25 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auth-service" +version = "0.1.0" +dependencies = [ + "axum", + "bcrypt", + "chrono", + "lettre", + "rand 0.9.4", + "serde", + "serde_json", + "shared", + "sqlx", + "tracing", + "tracing-subscriber", + "uuid", + "validator", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -197,30 +216,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bittuly" -version = "0.1.0" -dependencies = [ - "axum", - "base62", - "bcrypt", - "chrono", - "dotenvy", - "jsonwebtoken", - "lettre", - "rand 0.9.4", - "redis", - "serde", - "serde_json", - "sqlx", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", - "validator", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -232,9 +227,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -269,9 +264,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "shlex", @@ -296,9 +291,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -547,7 +542,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "ctutils", ] @@ -914,9 +909,9 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -1769,9 +1764,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redis" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" +checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" dependencies = [ "arc-swap", "arcstr", @@ -1806,9 +1801,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1829,9 +1824,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rfc6979" @@ -2073,6 +2068,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared" +version = "0.1.0" +dependencies = [ + "axum", + "dotenvy", + "jsonwebtoken", + "redis", + "serde", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2689,6 +2700,29 @@ dependencies = [ "serde", ] +[[package]] +name = "url-service" +version = "0.1.0" +dependencies = [ + "axum", + "base62", + "bcrypt", + "chrono", + "dotenvy", + "jsonwebtoken", + "redis", + "serde", + "serde_json", + "shared", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "validator", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3122,9 +3156,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/Cargo.toml b/Cargo.toml index 348622c..97ee9ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,28 @@ -[package] -name = "bittuly" -version = "0.1.0" -edition = "2024" +# [package] +# name = "bittuly" +# version = "0.1.0" +# edition = "2024" -[dependencies] -lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] } -rand = "0.9" -axum = "0.8.9" -base62 = "2.2.4" -bcrypt = "0.15" -chrono = { version = "0.4.44", features = ["serde"] } -dotenvy = "0.15.7" -jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1" -sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } -tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } -tower-http = { version = "0.6.11", features = ["cors", "trace"] } -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } -uuid = { version = "1.23.2", features = ["serde", "v4"] } -validator = { version = "0.20", features = ["derive"] } -redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } +[workspace] +members = ["libs/shared", "services/auth-service", "services/url-service"] +resolver = "3" + +# [dependencies] +# lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] } +# rand = "0.9" +# axum = "0.8.9" +# base62 = "2.2.4" +# bcrypt = "0.15" +# chrono = { version = "0.4.44", features = ["serde"] } +# dotenvy = "0.15.7" +# jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } +# serde = { version = "1.0.228", features = ["derive"] } +# serde_json = "1" +# sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } +# tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } +# tower-http = { version = "0.6.11", features = ["cors", "trace"] } +# tracing = "0.1.44" +# tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +# uuid = { version = "1.23.2", features = ["serde", "v4"] } +# validator = { version = "0.20", features = ["derive"] } +# redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } diff --git a/docker/postgres-auth/init/01-init.sql b/docker/postgres-auth/init/01-init.sql new file mode 100644 index 0000000..5f22af9 --- /dev/null +++ b/docker/postgres-auth/init/01-init.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/docker/postgres/init/01-init-schema.sql b/docker/postgres-urls/init/01-init.sql similarity index 58% rename from docker/postgres/init/01-init-schema.sql rename to docker/postgres-urls/init/01-init.sql index eee3f1f..da263d6 100644 --- a/docker/postgres/init/01-init-schema.sql +++ b/docker/postgres-urls/init/01-init.sql @@ -1,19 +1,11 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); +CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE TABLE IF NOT EXISTS urls ( url_id BIGSERIAL PRIMARY KEY, short_code TEXT UNIQUE, original_url TEXT NOT NULL, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id UUID NOT NULL, click_count BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -22,3 +14,4 @@ CREATE TABLE IF NOT EXISTS urls ( CREATE INDEX IF NOT EXISTS idx_urls_user_id ON urls(user_id); CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code); +CREATE INDEX IF NOT EXISTS idx_urls_original_url_trgm ON urls USING GIN (original_url gin_trgm_ops); diff --git a/docker/postgres/init/02-add-click-count.sql b/docker/postgres/init/02-add-click-count.sql deleted file mode 100644 index 1fb9924..0000000 --- a/docker/postgres/init/02-add-click-count.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Live migration: add click_count to urls table --- Run this against the existing database if you're not doing a fresh init. -ALTER TABLE urls ADD COLUMN IF NOT EXISTS click_count BIGINT NOT NULL DEFAULT 0; diff --git a/docker/postgres/init/03-add-search-indexes.sql b/docker/postgres/init/03-add-search-indexes.sql deleted file mode 100644 index 80d47e1..0000000 --- a/docker/postgres/init/03-add-search-indexes.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE INDEX IF NOT EXISTS idx_urls_original_url_trgm ON urls USING GIN (original_url gin_trgm_ops); diff --git a/services/url-service/Cargo.toml b/services/url-service/Cargo.toml new file mode 100644 index 0000000..b2b71fa --- /dev/null +++ b/services/url-service/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "url-service" +version = "0.1.0" +edition = "2024" + +[dependencies] +shared = { path = "../../libs/shared" } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.23.2", features = ["serde", "v4"] } +redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } +dotenvy = "0.15.7" +jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } +tower-http = { version = "0.6.11", features = ["cors", "trace"] } +axum = "0.8.9" +base62 = "2.2.4" +bcrypt = "0.15" +validator = { version = "0.20", features = ["derive"] } +chrono = { version = "0.4.44", features = ["serde"] } diff --git a/src/handlers/url_handler.rs b/services/url-service/src/handlers.rs similarity index 97% rename from src/handlers/url_handler.rs rename to services/url-service/src/handlers.rs index eacb877..32cbd02 100644 --- a/src/handlers/url_handler.rs +++ b/services/url-service/src/handlers.rs @@ -1,6 +1,4 @@ -use crate::{ - app::state::AppState, db::postgres::DbPool, middlewares::jwt::Claims, services::url_service, -}; +use crate::services as url_service; use axum::{ extract::{Extension, Json, Path, Query, State}, http::StatusCode, @@ -8,6 +6,7 @@ use axum::{ }; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; +use shared::{jwt::Claims, postgres::DbPool, state::AppState}; use std::sync::Arc; use validator::Validate; @@ -44,7 +43,7 @@ pub async fn get_all_urls( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "invalid cursor" })), ) - .into_response() + .into_response(); } }, }; @@ -68,15 +67,12 @@ pub async fn get_all_urls( } } - #[derive(Deserialize, Validate)] pub struct ShortenUrlRequest { #[validate(url)] pub original_url: String, } - - pub async fn shorten_url( State(db): State, Extension(claims): Extension, diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs new file mode 100644 index 0000000..67b0188 --- /dev/null +++ b/services/url-service/src/main.rs @@ -0,0 +1,9 @@ +mod handlers; +mod models; +mod repository; +mod routes; +mod services; + +pub struct UrlStruct; + +fn main() {} diff --git a/src/models/url/entity.rs b/services/url-service/src/models.rs similarity index 100% rename from src/models/url/entity.rs rename to services/url-service/src/models.rs diff --git a/src/repository/url_repository.rs b/services/url-service/src/repository.rs similarity index 93% rename from src/repository/url_repository.rs rename to services/url-service/src/repository.rs index aca2940..6065103 100644 --- a/src/repository/url_repository.rs +++ b/services/url-service/src/repository.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use crate::{db::postgres::DbPool, models::Url}; +use crate::models::Url; +use shared::postgres::DbPool; use uuid::Uuid; /// Creates a shortened URL. @@ -109,7 +110,7 @@ pub async fn get_urls_page( ) .bind(user_id) .bind(cursor) - .bind(limit + 1) // fetch one extra to detect next page + .bind(limit + 1) // fetch one extra to detect next page .bind(search_pattern) .fetch_all(db) .await?; @@ -137,13 +138,12 @@ pub async fn delete_url( url_id: i64, user_id: Uuid, ) -> Result, sqlx::Error> { - let row: Option<(String,)> = sqlx::query_as( - "DELETE FROM urls WHERE url_id = $1 AND user_id = $2 RETURNING short_code", - ) - .bind(url_id) - .bind(user_id) - .fetch_optional(db) - .await?; + let row: Option<(String,)> = + sqlx::query_as("DELETE FROM urls WHERE url_id = $1 AND user_id = $2 RETURNING short_code") + .bind(url_id) + .bind(user_id) + .fetch_optional(db) + .await?; Ok(row.map(|(short_code,)| short_code)) } @@ -174,4 +174,3 @@ pub async fn increment_click_counts( Ok(()) } - diff --git a/src/routes/url_routes.rs b/services/url-service/src/routes.rs similarity index 64% rename from src/routes/url_routes.rs rename to services/url-service/src/routes.rs index 89001f1..687e48c 100644 --- a/src/routes/url_routes.rs +++ b/services/url-service/src/routes.rs @@ -5,11 +5,8 @@ use axum::{ routing::{get, post}, }; -use crate::{ - db::postgres::DbPool, - handlers::url_handler::{delete_url_handler, get_all_urls, get_original_url, shorten_url}, - middlewares::jwt::jwt_auth, -}; +use crate::handlers::{delete_url_handler, get_all_urls, get_original_url, shorten_url}; +use shared::{jwt::jwt_auth, postgres::DbPool}; pub fn url_routes() -> Router { // POST / and GET / both require auth @@ -22,7 +19,6 @@ pub fn url_routes() -> Router { // GET /{id} is public; DELETE /{id} requires auth — applied at handler level .route( "/{id}", - get(get_original_url) - .delete(delete_url_handler.layer(middleware::from_fn(jwt_auth))), + get(get_original_url).delete(delete_url_handler.layer(middleware::from_fn(jwt_auth))), ) } diff --git a/src/services/url_service.rs b/services/url-service/src/services.rs similarity index 91% rename from src/services/url_service.rs rename to services/url-service/src/services.rs index ea8fe11..cb6981b 100644 --- a/src/services/url_service.rs +++ b/services/url-service/src/services.rs @@ -1,4 +1,5 @@ -use crate::{db::postgres::DbPool, models::Url, repository::url_repository}; +use crate::{models::Url, repository as url_repository}; +use shared::postgres::DbPool; use uuid::Uuid; pub async fn shorten_url( diff --git a/src/app/mod.rs b/src/app/mod.rs deleted file mode 100644 index 266c62a..0000000 --- a/src/app/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod state; diff --git a/src/app/state.rs b/src/app/state.rs deleted file mode 100644 index ac715cd..0000000 --- a/src/app/state.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::db::redis::RedisConn; -use std::time::Instant; -use tokio::sync::mpsc::UnboundedSender; - -pub type ClickSender = UnboundedSender; - -pub struct AppState { - pub tx: ClickSender, - pub redis: RedisConn, - pub started_at: Instant, -} - -impl AppState { - pub fn new(tx: ClickSender, redis: RedisConn) -> Self { - Self { - tx, - redis, - started_at: Instant::now(), - } - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 6e98cef..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod settings; diff --git a/src/config/settings.rs b/src/config/settings.rs deleted file mode 100644 index 6c1727c..0000000 --- a/src/config/settings.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::env; - -pub struct Settings { - pub database_url: String, - pub redis_url: String, - pub server_addr: String, - /// "development" | "production" - pub mode: String, - /// Allowed CORS origin, e.g. "http://localhost:5173" - pub cors_origin: String, -} - -impl Settings { - pub fn from_env() -> Result { - dotenvy::dotenv().ok(); - - let database_url = env::var("DATABASE_URL")?; - let redis_url = env::var("REDIS_URL") - .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); - let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_owned()); - let port = env::var("PORT").unwrap_or_else(|_| "3000".to_owned()); - let mode = env::var("MODE").unwrap_or_else(|_| "production".to_owned()); - let cors_origin = env::var("CORS_ORIGIN") - .unwrap_or_else(|_| "http://localhost:5173".to_owned()); - - Ok(Self { - database_url, - redis_url, - server_addr: format!("{host}:{port}"), - mode, - cors_origin, - }) - } -} diff --git a/src/db/mod.rs b/src/db/mod.rs deleted file mode 100644 index b51b1df..0000000 --- a/src/db/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod postgres; -pub mod redis; diff --git a/src/db/postgres.rs b/src/db/postgres.rs deleted file mode 100644 index 9c6987f..0000000 --- a/src/db/postgres.rs +++ /dev/null @@ -1,10 +0,0 @@ -use sqlx::{PgPool, postgres::PgPoolOptions}; - -pub type DbPool = PgPool; - -pub async fn init_pg_pool(database_url: &str) -> Result { - PgPoolOptions::new() - .max_connections(5) - .connect(database_url) - .await -} diff --git a/src/db/redis.rs b/src/db/redis.rs deleted file mode 100644 index b32ecaf..0000000 --- a/src/db/redis.rs +++ /dev/null @@ -1,22 +0,0 @@ -use redis::{Client, RedisResult, aio::ConnectionManager}; - -/// Shared Redis handle for the entire application. -/// -/// `ConnectionManager` wraps a `MultiplexedConnection` and automatically -/// reconnects on network drops — essential for a distributed URL shortener -/// where Redis availability must be treated as a soft dependency. -/// -/// It is `Clone + Send + Sync`, so it can live in `Arc` and be -/// cloned cheaply into every Axum handler. -pub type RedisConn = ConnectionManager; - -/// Connect to Redis and return a `ConnectionManager`. -/// -/// Establishes one multiplexed TCP connection at startup and keeps it alive, -/// transparently reconnecting whenever the link is lost. -/// -/// Requires the `connection-manager` + `tokio-comp` feature flags. -pub async fn init_redis(url: &str) -> RedisResult { - let client = Client::open(url)?; - ConnectionManager::new(client).await -} diff --git a/src/handlers/debug_handler.rs b/src/handlers/debug_handler.rs deleted file mode 100644 index 5ba44bb..0000000 --- a/src/handlers/debug_handler.rs +++ /dev/null @@ -1,8 +0,0 @@ -use axum::{Json, response::IntoResponse}; -use crate::utils::otp_store; - -/// GET /debug/otp-store -/// Returns all stored OTP entries. Only available when MODE=development. -pub async fn debug_otp_store_handler() -> impl IntoResponse { - Json(otp_store::get_all()) -} diff --git a/src/handlers/health_handler.rs b/src/handlers/health_handler.rs deleted file mode 100644 index 4d9e19c..0000000 --- a/src/handlers/health_handler.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::sync::Arc; - -use axum::{ - extract::{Extension, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Serialize; - -use crate::{app::state::AppState, db::postgres::DbPool}; - -#[derive(Serialize)] -pub struct HealthResponse { - /// "healthy" if all checks pass, "degraded" if any fail. - pub status: &'static str, - pub postgres: String, - pub redis: String, - /// Crate version from Cargo.toml at compile time. - pub version: &'static str, - /// Seconds since the server started. - pub uptime_secs: u64, -} - -/// `GET /health` -/// -/// Public endpoint — no authentication required. -/// Returns 200 when all dependencies are reachable, 503 otherwise. -pub async fn health( - State(db): State, - Extension(state): Extension>, -) -> impl IntoResponse { - let uptime_secs = state.started_at.elapsed().as_secs(); - - // ── Postgres: send a trivial query ─────────────────────────────────────── - let postgres = match sqlx::query("SELECT 1").execute(&db).await { - Ok(_) => Ok("ok".to_owned()), - Err(e) => { - tracing::warn!("health check: postgres failed: {e}"); - Err(format!("error: {e}")) - } - }; - - // ── Redis: PING ─────────────────────────────────────────────────────────── - let redis = { - let mut conn = state.redis.clone(); - match redis::cmd("PING") - .query_async::(&mut conn) - .await - { - Ok(_) => Ok("ok".to_owned()), - Err(e) => { - tracing::warn!("health check: redis failed: {e}"); - Err(format!("error: {e}")) - } - } - }; - - let healthy = postgres.is_ok() && redis.is_ok(); - let http_status = if healthy { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - - ( - http_status, - Json(HealthResponse { - status: if healthy { "healthy" } else { "degraded" }, - postgres: postgres.unwrap_or_else(|e| e), - redis: redis.unwrap_or_else(|e| e), - version: env!("CARGO_PKG_VERSION"), - uptime_secs, - }), - ) -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index 9dc4307..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod debug_handler; -pub mod health_handler; -pub mod url_handler; -pub mod user_handler; diff --git a/src/handlers/user_handler.rs b/src/handlers/user_handler.rs deleted file mode 100644 index 7bbf2cc..0000000 --- a/src/handlers/user_handler.rs +++ /dev/null @@ -1,186 +0,0 @@ -use crate::db::postgres::DbPool; -use crate::middlewares::jwt::{Claims, clear_token_cookies, set_token_cookies}; -use crate::models::user::{CreateUserPayload, LoginPayload, UpdateUserPayload, VerifyOtpPayload}; -use crate::services::user_service; -use serde_json::json; -use axum::extract::{Extension, Json, Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use uuid::Uuid; -use validator::Validate; - -pub async fn create_user( - State(db): State, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - match user_service::create_user(&db, payload).await { - Ok(auth) => { - let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); - if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { - tracing::error!("set_token_cookies: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - response - } - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), - } -} - -pub async fn login( - State(db): State, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - match user_service::login(&db, &payload.email, &payload.password).await { - Ok(auth) => { - let mut response = (StatusCode::OK, Json(auth.user)).into_response(); - if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { - tracing::error!("set_token_cookies: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - response - } - Err(_) => StatusCode::UNAUTHORIZED.into_response(), - } -} - -pub async fn logout() -> impl IntoResponse { - let mut response = StatusCode::NO_CONTENT.into_response(); - clear_token_cookies(&mut response); - response -} - -pub async fn get_user_by_id( - State(db): State, - Extension(claims): Extension, - Path(user_id): Path, -) -> impl IntoResponse { - let user_id = match Uuid::parse_str(&user_id) { - Ok(id) => id, - Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), - }; - - if user_id != claims.sub { - return StatusCode::FORBIDDEN.into_response(); - } - - match user_service::get_user_by_id(&db, user_id).await { - Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), - } -} - -pub async fn update_user( - State(db): State, - Extension(claims): Extension, - Path(user_id): Path, - Json(payload): Json, -) -> impl IntoResponse { - let user_id = match Uuid::parse_str(&user_id) { - Ok(id) => id, - Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), - }; - - if user_id != claims.sub { - return StatusCode::FORBIDDEN.into_response(); - } - - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - - match user_service::update_user(&db, user_id, payload).await { - Ok(auth) => { - let mut response = (StatusCode::OK, Json(auth.user)).into_response(); - if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { - tracing::error!("set_token_cookies: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - response - } - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), - } -} - -pub async fn delete_user( - State(db): State, - Extension(claims): Extension, - Path(user_id): Path, -) -> impl IntoResponse { - let user_id = match Uuid::parse_str(&user_id) { - Ok(id) => id, - Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), - }; - - if user_id != claims.sub { - return StatusCode::FORBIDDEN.into_response(); - } - - match user_service::delete_user(&db, user_id).await { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), - } -} - -// --------------------------------------------------------------------------- -// OTP signup flow handlers -// --------------------------------------------------------------------------- - -/// POST /users/signup -/// Validates payload, sends OTP email, returns { pending_token } (no user created yet). -pub async fn request_signup_handler( - State(db): State, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - match user_service::request_signup(&db, payload).await { - Ok(pending_token) => ( - StatusCode::OK, - Json(json!({ "pending_token": pending_token })), - ) - .into_response(), - Err(err) if err.to_string() == "email already registered" => { - (StatusCode::CONFLICT, Json(json!({ "error": err.to_string() }))).into_response() - } - Err(err) => { - tracing::error!("request_signup: {:?}", err); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} - -/// POST /users/verify-otp -/// Verifies OTP, creates the real user, sets auth cookies, returns user. -pub async fn verify_otp_handler( - State(db): State, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - match user_service::verify_otp(&db, &payload.pending_token, &payload.otp).await { - Ok(auth) => { - let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); - if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { - tracing::error!("set_token_cookies: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - response - } - Err(err) if err.to_string() == "invalid OTP" => { - (StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid or expired OTP" }))).into_response() - } - Err(err) => { - tracing::error!("verify_otp: {:?}", err); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f63ac0a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,120 +0,0 @@ -mod app; -mod config; -mod db; -mod handlers; -mod middlewares; -mod models; -mod repository; -mod routes; -mod services; -mod utils; - -use std::{collections::HashMap, sync::Arc}; - -use app::state::AppState; -use config::settings::Settings; -use db::{postgres::init_pg_pool, redis::init_redis}; -use repository::url_repository; -use routes::router::create_router; -use tokio::{net::TcpListener, sync::mpsc}; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("tower_http=debug,bittuly=info")), - ) - .init(); - - let settings = Settings::from_env().expect("failed to load settings"); - let db = init_pg_pool(&settings.database_url) - .await - .expect("failed to connect to database"); - let redis = init_redis(&settings.redis_url) - .await - .expect("failed to connect to redis"); - - let (tx, mut rx) = mpsc::unbounded_channel::(); - let app_state = Arc::new(AppState::new(tx.clone(), redis)); - - // Consumer task — two flush triggers: - // 1. Size : every 17 accumulated click events - // 2. Timer : every 30 seconds (so low-traffic links are never stuck) - let consumer_db = db.clone(); - let consumer_handler = tokio::spawn(async move { - let mut batch: HashMap = HashMap::new(); - let mut total_clicks: u64 = 0; - - let mut flush_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); - // Consume the immediate first tick so the timer starts 30 s from now, - // not from the moment the task spawns. - flush_interval.tick().await; - - loop { - tokio::select! { - // ── Arm 1: new click event from the channel ────────────────── - maybe_code = rx.recv() => { - match maybe_code { - Some(short_code) => { - *batch.entry(short_code).or_insert(0) += 1; - total_clicks += 1; - - if total_clicks >= 17 { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (size trigger)"), - Err(e) => tracing::error!("click batch flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - None => { - // Channel closed (server shutting down) — drain remainder - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (shutdown drain)"), - Err(e) => tracing::error!("click batch final flush failed: {e}"), - } - } - break; - } - } - } - - // ── Arm 2: periodic 30-second flush ────────────────────────── - _ = flush_interval.tick() => { - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (interval trigger)"), - Err(e) => tracing::error!("click batch interval flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - } - } - }); - - let app = create_router(db, &settings.mode, &settings.cors_origin, app_state); - let listener = TcpListener::bind(&settings.server_addr) - .await - .expect("failed to bind listener"); - - println!( - "listening on {} [mode={} cors={}]", - settings.server_addr, settings.mode, settings.cors_origin - ); - - if let Err(err) = axum::serve(listener, app).await { - eprintln!("server error: {err}"); - } - - // Signal consumer to stop and wait for it to drain - drop(tx); - consumer_handler.await.unwrap(); -} diff --git a/src/middlewares/jwt.rs b/src/middlewares/jwt.rs deleted file mode 100644 index 9df7bd9..0000000 --- a/src/middlewares/jwt.rs +++ /dev/null @@ -1,206 +0,0 @@ -use axum::{ - extract::Request, - http::{HeaderValue, StatusCode, header}, - middleware::Next, - response::Response, -}; -use jsonwebtoken::{ - DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind, -}; -use serde::{Deserialize, Serialize}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -const ACCESS_TOKEN_TYPE: &str = "access"; -const REFRESH_TOKEN_TYPE: &str = "refresh"; -const PENDING_TOKEN_TYPE: &str = "pending"; -const ACCESS_TOKEN_TTL_SECONDS: u64 = 60 * 15; // 15 minutes -const REFRESH_TOKEN_TTL_SECONDS: u64 = 60 * 60 * 24 * 30; // 30 days -const PENDING_TOKEN_TTL_SECONDS: u64 = 60 * 10; // 10 minutes -const COOKIE_ACCESS: &str = "access_token"; -const COOKIE_REFRESH: &str = "refresh_token"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Claims { - pub sub: Uuid, - pub exp: usize, - pub token_type: String, -} - -fn create_token( - user_id: Uuid, - token_type: &str, - ttl_seconds: u64, -) -> Result> { - let secret = std::env::var("JWT_SECRET")?; - let exp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + ttl_seconds; - let claims = Claims { sub: user_id, exp: exp as usize, token_type: token_type.to_string() }; - Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) -} - -pub fn create_access_token(user_id: Uuid) -> Result> { - create_token(user_id, ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TTL_SECONDS) -} - -pub fn create_refresh_token(user_id: Uuid) -> Result> { - create_token(user_id, REFRESH_TOKEN_TYPE, REFRESH_TOKEN_TTL_SECONDS) -} - -// --------------------------------------------------------------------------- -// Pending signup JWT — carries encrypted signup data until OTP is verified -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OtpClaims { - /// Identifies this as a pending-signup token, not an auth token. - pub token_type: String, - pub email: String, - pub username: String, - pub password_hash: String, - pub otp_hash: String, - pub exp: usize, -} - -/// Issue a short-lived JWT that holds the pending user's data + OTP hash. -/// The client stores this and sends it back with the OTP code. -pub fn create_pending_token( - email: &str, - username: &str, - password_hash: &str, - otp_hash: &str, -) -> Result> { - let secret = std::env::var("JWT_SECRET")?; - let exp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + PENDING_TOKEN_TTL_SECONDS; - let claims = OtpClaims { - token_type: PENDING_TOKEN_TYPE.to_string(), - email: email.to_string(), - username: username.to_string(), - password_hash: password_hash.to_string(), - otp_hash: otp_hash.to_string(), - exp: exp as usize, - }; - Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) -} - -/// Decode and validate a pending signup JWT. -/// Returns an error if the token is expired, tampered, or not a pending token. -pub fn decode_pending_token( - token: &str, -) -> Result> { - let secret = std::env::var("JWT_SECRET")?; - let data = decode::( - token, - &DecodingKey::from_secret(secret.as_bytes()), - &Validation::default(), - )?; - if data.claims.token_type != PENDING_TOKEN_TYPE { - return Err("invalid token type".into()); - } - Ok(data.claims) -} - -/// Parse a single cookie value from a `Cookie: a=1; b=2` header string. -fn parse_cookie(cookie_header: &str, name: &str) -> Option { - cookie_header.split(';').find_map(|pair| { - let pair = pair.trim(); - let (k, v) = pair.split_once('=')?; - (k.trim() == name).then(|| v.trim().to_owned()) - }) -} - -/// Append `Set-Cookie` headers for both tokens. -/// In debug builds `Secure` is omitted so plain-HTTP local testing works. -pub fn set_token_cookies( - response: &mut Response, - access_token: &str, - refresh_token: &str, -) -> Result<(), header::InvalidHeaderValue> { - #[cfg(debug_assertions)] - let flags = "HttpOnly; SameSite=Strict"; - #[cfg(not(debug_assertions))] - let flags = "HttpOnly; Secure; SameSite=Strict"; - - let access = format!("{COOKIE_ACCESS}={access_token}; {flags}; Max-Age={ACCESS_TOKEN_TTL_SECONDS}; Path=/"); - let refresh = format!("{COOKIE_REFRESH}={refresh_token}; {flags}; Max-Age={REFRESH_TOKEN_TTL_SECONDS}; Path=/"); - - response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&access)?); - response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&refresh)?); - Ok(()) -} - -/// Append `Set-Cookie` headers that instruct the browser to delete both cookies. -pub fn clear_token_cookies(response: &mut Response) { - for name in [COOKIE_ACCESS, COOKIE_REFRESH] { - let value = format!("{name}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/"); - response.headers_mut().append( - header::SET_COOKIE, - HeaderValue::from_str(&value).expect("static clear-cookie value is always valid"), - ); - } -} - -pub async fn jwt_auth(mut req: Request, next: Next) -> Result { - // Clone to String so we release the immutable borrow on `req` before mutating it. - let cookie_str = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .ok_or(StatusCode::UNAUTHORIZED)? - .to_owned(); - - let access_token = parse_cookie(&cookie_str, COOKIE_ACCESS).ok_or(StatusCode::UNAUTHORIZED)?; - - let secret = std::env::var("JWT_SECRET").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - match decode::( - &access_token, - &DecodingKey::from_secret(secret.as_bytes()), - &Validation::default(), - ) { - Ok(data) if data.claims.token_type == ACCESS_TOKEN_TYPE => { - req.extensions_mut().insert(data.claims); - Ok(next.run(req).await) - } - Ok(_) => Err(StatusCode::UNAUTHORIZED), - Err(err) if matches!(err.kind(), ErrorKind::ExpiredSignature) => { - refresh_access_token(req, next, &cookie_str, &secret).await - } - Err(_) => Err(StatusCode::UNAUTHORIZED), - } -} - -async fn refresh_access_token( - mut req: Request, - next: Next, - cookie_str: &str, - secret: &str, -) -> Result { - let refresh_token = - parse_cookie(cookie_str, COOKIE_REFRESH).ok_or(StatusCode::UNAUTHORIZED)?; - - let data = decode::( - &refresh_token, - &DecodingKey::from_secret(secret.as_bytes()), - &Validation::default(), - ) - .map_err(|_| StatusCode::UNAUTHORIZED)?; - - if data.claims.token_type != REFRESH_TOKEN_TYPE { - return Err(StatusCode::UNAUTHORIZED); - } - - let user_id = data.claims.sub; - let new_access = create_access_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let new_refresh = create_refresh_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - req.extensions_mut().insert(Claims { - sub: user_id, - exp: data.claims.exp, - token_type: ACCESS_TOKEN_TYPE.to_string(), - }); - - let mut response = next.run(req).await; - set_token_cookies(&mut response, &new_access, &new_refresh) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(response) -} diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs deleted file mode 100644 index 417233c..0000000 --- a/src/middlewares/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod jwt; diff --git a/src/models/mod.rs b/src/models/mod.rs deleted file mode 100644 index 6ea0189..0000000 --- a/src/models/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod url; -pub mod user; - -pub use url::Url; diff --git a/src/models/url/mod.rs b/src/models/url/mod.rs deleted file mode 100644 index bb0386e..0000000 --- a/src/models/url/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod entity; - -pub use entity::Url; diff --git a/src/models/user/entity.rs b/src/models/user/entity.rs deleted file mode 100644 index b4f50b7..0000000 --- a/src/models/user/entity.rs +++ /dev/null @@ -1,60 +0,0 @@ -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use uuid::Uuid; -use validator::Validate; - -#[derive(sqlx::FromRow, Serialize)] -pub struct User { - pub id: Uuid, - pub username: String, - pub email: String, - #[serde(skip)] - pub password: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Serialize)] -pub struct AuthUserResponse { - pub user: User, - pub token: String, - pub refresh_token: String, -} - -#[derive(Deserialize, Validate)] -pub struct CreateUserPayload { - #[validate(length(min = 3, max = 50))] - pub username: String, - #[validate(email)] - pub email: String, - #[validate(length(min = 6))] - pub password: String, -} - -#[derive(Deserialize, Validate)] -pub struct UpdateUserPayload { - #[validate(length(min = 3, max = 50))] - pub username: Option, - #[validate(email)] - pub email: Option, - #[validate(length(min = 6))] - pub password: Option, -} - -#[derive(Deserialize, Validate)] -pub struct LoginPayload { - #[validate(email)] - pub email: String, - pub password: String, -} - -#[derive(Deserialize, Validate)] -pub struct VerifyOtpPayload { - /// The short-lived pending JWT issued by POST /users/signup. - pub pending_token: String, - /// The 6-digit OTP the user received by email. - #[validate(length(min = 6, max = 6))] - pub otp: String, -} diff --git a/src/models/user/mod.rs b/src/models/user/mod.rs deleted file mode 100644 index f888bdc..0000000 --- a/src/models/user/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod entity; - -pub use entity::AuthUserResponse; -pub use entity::CreateUserPayload; -pub use entity::LoginPayload; -pub use entity::UpdateUserPayload; -pub use entity::User; -pub use entity::VerifyOtpPayload; diff --git a/src/repository/mod.rs b/src/repository/mod.rs deleted file mode 100644 index 6411061..0000000 --- a/src/repository/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod url_repository; -pub mod user_repository; diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs deleted file mode 100644 index c111ad5..0000000 --- a/src/repository/user_repository.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::db::postgres::DbPool; -use crate::models::user::{CreateUserPayload, UpdateUserPayload, User}; -use uuid::Uuid; - -pub async fn create_user(db: &DbPool, payload: CreateUserPayload) -> Result { - sqlx::query_as( - r#" - INSERT INTO users (id, username, email, password) - VALUES ($1, $2, $3, $4) - RETURNING id, username, email, password, created_at, updated_at - "#, - ) - .bind(Uuid::new_v4()) - .bind(&payload.username) - .bind(&payload.email) - .bind(&payload.password) - .fetch_one(db) - .await -} - -pub async fn get_user_by_id(db: &DbPool, user_id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as( - "SELECT id, username, email, password, created_at, updated_at FROM users WHERE id = $1", - ) - .bind(user_id) - .fetch_optional(db) - .await -} - -pub async fn get_user_by_email(db: &DbPool, email: &str) -> Result, sqlx::Error> { - sqlx::query_as( - "SELECT id, username, email, password, created_at, updated_at FROM users WHERE email = $1", - ) - .bind(email) - .fetch_optional(db) - .await -} - -pub async fn update_user( - db: &DbPool, - user_id: Uuid, - payload: UpdateUserPayload, -) -> Result { - sqlx::query_as( - r#" - UPDATE users - SET - username = COALESCE($1, username), - email = COALESCE($2, email), - password = COALESCE($3, password), - updated_at = NOW() - WHERE id = $4 - RETURNING id, username, email, password, created_at, updated_at - "#, - ) - .bind(payload.username) - .bind(payload.email) - .bind(payload.password) - .bind(user_id) - .fetch_one(db) - .await -} - -pub async fn delete_user(db: &DbPool, user_id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM users WHERE id = $1") - .bind(user_id) - .execute(db) - .await?; - Ok(()) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs deleted file mode 100644 index e4db2cb..0000000 --- a/src/routes/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod router; -pub mod url_routes; -pub mod user_routes; diff --git a/src/routes/router.rs b/src/routes/router.rs deleted file mode 100644 index 6aa0334..0000000 --- a/src/routes/router.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::sync::Arc; - -use axum::Router; -use axum::Extension; -use axum::http::{HeaderValue, Method, header}; -use axum::routing::get; -use tower_http::cors::CorsLayer; -use tower_http::trace::TraceLayer; - -use crate::app::state::AppState; -use crate::{ - db::postgres::DbPool, - handlers::{ - debug_handler::debug_otp_store_handler, - health_handler::health, - }, - routes::{url_routes::url_routes, user_routes::user_routes}, -}; - -pub fn create_router(db: DbPool, mode: &str, cors_origin: &str, state: Arc) -> Router { - let cors = CorsLayer::new() - .allow_origin( - cors_origin - .parse::() - .expect("Invalid CORS_ORIGIN value"), - ) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([header::CONTENT_TYPE]) - .allow_credentials(true); - - let mut router = Router::new() - .route("/health", get(health)) // public — no auth - .merge(url_routes()) - .nest("/users", user_routes()) - .layer(Extension(state)); - - if mode == "development" { - router = router.route("/debug/otp-store", get(debug_otp_store_handler)); - tracing::warn!("[DEV] GET /debug/otp-store is enabled — disable in production"); - } - - router - .layer(cors) - .layer(TraceLayer::new_for_http()) - .with_state(db) -} diff --git a/src/routes/user_routes.rs b/src/routes/user_routes.rs deleted file mode 100644 index d73de1a..0000000 --- a/src/routes/user_routes.rs +++ /dev/null @@ -1,26 +0,0 @@ -use axum::{middleware, Router}; -use axum::routing::{get, post}; - -use crate::{ - db::postgres::DbPool, - handlers::user_handler::{ - create_user, delete_user, get_user_by_id, login, logout, update_user, - request_signup_handler, verify_otp_handler, - }, - middlewares::jwt::jwt_auth, -}; - -pub fn user_routes() -> Router { - let protected = Router::new() - .route("/{user_id}", get(get_user_by_id).delete(delete_user).put(update_user)) - .route("/logout", post(logout)) - .layer(middleware::from_fn(jwt_auth)); - - Router::new() - .route("/signup", post(request_signup_handler)) // Step 1: send OTP - .route("/verify-otp", post(verify_otp_handler)) // Step 2: verify OTP → create user + JWT - .route("/direct-signup", post(create_user)) // Legacy: direct signup (no OTP) - .route("/login", post(login)) - .merge(protected) -} - diff --git a/src/services/mod.rs b/src/services/mod.rs deleted file mode 100644 index fcdbef5..0000000 --- a/src/services/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod url_service; -pub mod user_service; diff --git a/src/services/user_service.rs b/src/services/user_service.rs deleted file mode 100644 index dca055d..0000000 --- a/src/services/user_service.rs +++ /dev/null @@ -1,148 +0,0 @@ -use crate::db::postgres::DbPool; -use crate::middlewares::jwt::{ - create_access_token, create_pending_token, create_refresh_token, decode_pending_token, -}; -use crate::models::user::{AuthUserResponse, CreateUserPayload, UpdateUserPayload, User}; -use crate::repository::user_repository; -use crate::utils::email::send_otp_email; -use rand::Rng; -use uuid::Uuid; - -type ServiceResult = Result>; - -pub async fn create_user( - db: &DbPool, - mut payload: CreateUserPayload, -) -> ServiceResult { - payload.password = bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)?; - let user = user_repository::create_user(db, payload).await?; - let token = create_access_token(user.id)?; - let refresh_token = create_refresh_token(user.id)?; - - Ok(AuthUserResponse { - user, - token, - refresh_token, - }) -} - -// --------------------------------------------------------------------------- -// OTP signup flow -// --------------------------------------------------------------------------- - -/// Step 1 — validate + hash credentials, generate OTP, send email, return pending JWT. -/// -/// The pending JWT embeds { email, username, password_hash, otp_hash } and -/// expires in 10 minutes. No user row is created yet. -pub async fn request_signup( - db: &DbPool, - mut payload: CreateUserPayload, -) -> ServiceResult { - // Reject if email is already taken so we don't send an OTP pointlessly. - if user_repository::get_user_by_email(db, &payload.email).await?.is_some() { - return Err("email already registered".into()); - } - - // Hash the password before it goes anywhere near a JWT. - payload.password = bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)?; - - // Generate a 6-digit OTP and bcrypt-hash it for the pending token. - let otp: String = rand::rng() - .random_range(100_000u32..=999_999u32) - .to_string(); - let otp_hash = bcrypt::hash(&otp, bcrypt::DEFAULT_COST)?; - - // Fire the email first — if it fails we return early without issuing a token. - send_otp_email(&payload.email, &otp).await?; - - // Embed all pending data into a short-lived signed JWT. - let pending_token = create_pending_token( - &payload.email, - &payload.username, - &payload.password, - &otp_hash, - )?; - - Ok(pending_token) -} - -/// Step 2 — verify the OTP, create the real user row, issue auth JWTs. -/// -/// `pending_token` is the JWT returned by `request_signup`. -/// `otp` is the 6-digit code the user received by email. -pub async fn verify_otp( - db: &DbPool, - pending_token: &str, - otp: &str, -) -> ServiceResult { - // Decode and verify the pending JWT (signature + expiry checked by the library). - let claims = decode_pending_token(pending_token)?; - - // Verify the submitted OTP against the hashed one inside the token. - if !bcrypt::verify(otp, &claims.otp_hash)? { - return Err("invalid OTP".into()); - } - - // OTP is valid — build the CreateUserPayload with the already-hashed password. - let payload = CreateUserPayload { - username: claims.username, - email: claims.email, - password: claims.password_hash, // already bcrypt-hashed in request_signup - }; - - let user = user_repository::create_user(db, payload).await?; - let token = create_access_token(user.id)?; - let refresh_token = create_refresh_token(user.id)?; - - Ok(AuthUserResponse { - user, - token, - refresh_token, - }) -} - -pub async fn login(db: &DbPool, email: &str, password: &str) -> ServiceResult { - let user: User = user_repository::get_user_by_email(db, email) - .await? - .ok_or("invalid credentials")?; - - if !bcrypt::verify(password, &user.password)? { - return Err("invalid credentials".into()); - } - - let token = create_access_token(user.id)?; - let refresh_token = create_refresh_token(user.id)?; - - Ok(AuthUserResponse { - user, - token, - refresh_token, - }) -} - -pub async fn get_user_by_id(db: &DbPool, user_id: Uuid) -> Result, sqlx::Error> { - user_repository::get_user_by_id(db, user_id).await -} - -pub async fn update_user( - db: &DbPool, - user_id: Uuid, - mut payload: UpdateUserPayload, -) -> ServiceResult { - if let Some(ref plain) = payload.password { - payload.password = Some(bcrypt::hash(plain, bcrypt::DEFAULT_COST)?); - } - let user = user_repository::update_user(db, user_id, payload).await?; - let token = create_access_token(user.id)?; - let refresh_token = create_refresh_token(user.id)?; - - Ok(AuthUserResponse { - user, - token, - refresh_token, - }) -} - -pub async fn delete_user(db: &DbPool, user_id: Uuid) -> Result<(), sqlx::Error> { - user_repository::delete_user(db, user_id).await -} diff --git a/src/utils/email.rs b/src/utils/email.rs deleted file mode 100644 index d20fc42..0000000 --- a/src/utils/email.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::utils::otp_store; -use lettre::message::header::ContentType; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; - -type EmailResult = Result<(), Box>; - -/// Send a 6-digit OTP to `to_email`. -/// -/// Behaviour is controlled by the `MODE` environment variable: -/// - `development` → stores OTP in the in-memory DebugOtpStore (no real email sent) -/// - anything else → sends a real email via Gmail SMTP -pub async fn send_otp_email(to_email: &str, otp: &str) -> EmailResult { - let mode = std::env::var("MODE").unwrap_or_default(); - - if mode == "development" { - otp_store::store_otp(to_email, otp); - return Ok(()); - } - - // --- Production: real Gmail SMTP --- - let smtp_user = std::env::var("SMTP_USER")?; - let smtp_pass = std::env::var("SMTP_PASS")?; - - let email = Message::builder() - .from(format!("Bittuly <{smtp_user}>").parse()?) - .to(to_email.parse()?) - .subject("Your Bittuly verification code") - .header(ContentType::TEXT_PLAIN) - .body(format!( - "Your one-time verification code is: {otp}\n\nThis code expires in 10 minutes.\nIf you did not request this, please ignore this email." - ))?; - - let creds = Credentials::new(smtp_user, smtp_pass); - - let mailer = AsyncSmtpTransport::::relay("smtp.gmail.com")? - .credentials(creds) - .build(); - - mailer.send(email).await?; - - Ok(()) -} - diff --git a/src/utils/mod.rs b/src/utils/mod.rs deleted file mode 100644 index b1b6225..0000000 --- a/src/utils/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod email; -pub mod otp_store; diff --git a/src/utils/otp_store.rs b/src/utils/otp_store.rs deleted file mode 100644 index 586c071..0000000 --- a/src/utils/otp_store.rs +++ /dev/null @@ -1,49 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::Serialize; -use std::collections::HashMap; -use std::sync::{OnceLock, RwLock}; - -/// A single OTP entry stored during development. -#[derive(Debug, Clone, Serialize)] -pub struct OtpEntry { - pub email: String, - pub otp: String, - pub created_at: DateTime, -} - -/// Global in-memory OTP store — only populated when MODE=development. -/// -/// Keyed by email so only the most recent OTP per address is kept. -/// Re-requesting a signup overwrites the previous entry automatically. -static STORE: OnceLock>> = OnceLock::new(); - -fn store() -> &'static RwLock> { - STORE.get_or_init(|| RwLock::new(HashMap::new())) -} - -/// Insert (or overwrite) the latest OTP for the given email. -/// Called by send_otp_email() in development mode. -pub fn store_otp(email: &str, otp: &str) { - let entry = OtpEntry { - email: email.to_string(), - otp: otp.to_string(), - created_at: Utc::now(), - }; - if let Ok(mut guard) = store().write() { - guard.insert(email.to_string(), entry); - } - tracing::info!("[DEV OTP] email={} otp={}", email, otp); -} - -/// Return all stored OTP entries, sorted by most recent first. -pub fn get_all() -> Vec { - let entries = store() - .read() - .map(|g| g.values().cloned().collect::>()) - .unwrap_or_default(); - - let mut sorted = entries; - sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - sorted -} - diff --git a/tests/url_service.rs b/tests/url_service.rs deleted file mode 100644 index 64e74ff..0000000 --- a/tests/url_service.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(test)] -mod url_service_tests {} From 69720c7bad1c1a5d2f32861f8114a75c6e31e4f5 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 17:22:16 +0530 Subject: [PATCH 03/32] add libs/ & services/ --- libs/shared/.gitignore | 1 + libs/shared/Cargo.toml | 16 ++ libs/shared/src/config.rs | 34 ++++ libs/shared/src/error.rs | 0 libs/shared/src/health.rs | 73 ++++++++ libs/shared/src/jwt.rs | 206 +++++++++++++++++++++ libs/shared/src/lib.rs | 7 + libs/shared/src/postgres.rs | 10 + libs/shared/src/redis.rs | 22 +++ libs/shared/src/state.rs | 21 +++ services/auth-service/.gitignore | 1 + services/auth-service/Cargo.toml | 19 ++ services/auth-service/src/debug_handler.rs | 8 + services/auth-service/src/email.rs | 43 +++++ services/auth-service/src/handlers.rs | 190 +++++++++++++++++++ services/auth-service/src/main.rs | 12 ++ services/auth-service/src/models.rs | 60 ++++++ services/auth-service/src/otp_store.rs | 48 +++++ services/auth-service/src/repository.rs | 70 +++++++ services/auth-service/src/routes.rs | 24 +++ services/auth-service/src/service.rs | 148 +++++++++++++++ 21 files changed, 1013 insertions(+) create mode 100644 libs/shared/.gitignore create mode 100644 libs/shared/Cargo.toml create mode 100644 libs/shared/src/config.rs create mode 100644 libs/shared/src/error.rs create mode 100644 libs/shared/src/health.rs create mode 100644 libs/shared/src/jwt.rs create mode 100644 libs/shared/src/lib.rs create mode 100644 libs/shared/src/postgres.rs create mode 100644 libs/shared/src/redis.rs create mode 100644 libs/shared/src/state.rs create mode 100644 services/auth-service/.gitignore create mode 100644 services/auth-service/Cargo.toml create mode 100644 services/auth-service/src/debug_handler.rs create mode 100644 services/auth-service/src/email.rs create mode 100644 services/auth-service/src/handlers.rs create mode 100644 services/auth-service/src/main.rs create mode 100644 services/auth-service/src/models.rs create mode 100644 services/auth-service/src/otp_store.rs create mode 100644 services/auth-service/src/repository.rs create mode 100644 services/auth-service/src/routes.rs create mode 100644 services/auth-service/src/service.rs diff --git a/libs/shared/.gitignore b/libs/shared/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/libs/shared/.gitignore @@ -0,0 +1 @@ +/target diff --git a/libs/shared/Cargo.toml b/libs/shared/Cargo.toml new file mode 100644 index 0000000..b2ad5ef --- /dev/null +++ b/libs/shared/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2024" + +[dependencies] +jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } +serde = { version = "1.0.228", features = ["derive"] } +dotenvy = "0.15.7" +uuid = { version = "1.23.2", features = ["serde", "v4"] } +axum = "0.8.9" +sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } +redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } diff --git a/libs/shared/src/config.rs b/libs/shared/src/config.rs new file mode 100644 index 0000000..eeffbe7 --- /dev/null +++ b/libs/shared/src/config.rs @@ -0,0 +1,34 @@ +use std::env; + +pub struct Settings { + pub database_url: String, + pub redis_url: String, + pub server_addr: String, + /// "development" | "production" + pub mode: String, + /// Allowed CORS origin, e.g. "http://localhost:5173" + pub cors_origin: String, +} + +impl Settings { + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); + + let database_url = env::var("DATABASE_URL")?; + let redis_url = + env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); + let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_owned()); + let port = env::var("PORT").unwrap_or_else(|_| "3000".to_owned()); + let mode = env::var("MODE").unwrap_or_else(|_| "production".to_owned()); + let cors_origin = + env::var("CORS_ORIGIN").unwrap_or_else(|_| "http://localhost:5173".to_owned()); + + Ok(Self { + database_url, + redis_url, + server_addr: format!("{host}:{port}"), + mode, + cors_origin, + }) + } +} diff --git a/libs/shared/src/error.rs b/libs/shared/src/error.rs new file mode 100644 index 0000000..e69de29 diff --git a/libs/shared/src/health.rs b/libs/shared/src/health.rs new file mode 100644 index 0000000..15678a1 --- /dev/null +++ b/libs/shared/src/health.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Serialize; + +use crate::{postgres::DbPool, state::AppState}; + +#[derive(Serialize)] +pub struct HealthResponse { + /// "healthy" if all checks pass, "degraded" if any fail. + pub status: &'static str, + pub postgres: String, + pub redis: String, + /// Crate version from Cargo.toml at compile time. + pub version: &'static str, + /// Seconds since the server started. + pub uptime_secs: u64, +} + +/// `GET /health` +/// +/// Public endpoint — no authentication required. +/// Returns 200 when all dependencies are reachable, 503 otherwise. +pub async fn health( + State(db): State, + Extension(state): Extension>, +) -> impl IntoResponse { + let uptime_secs = state.started_at.elapsed().as_secs(); + + // ── Postgres: send a trivial query ─────────────────────────────────────── + let postgres = match sqlx::query("SELECT 1").execute(&db).await { + Ok(_) => Ok("ok".to_owned()), + Err(e) => { + tracing::warn!("health check: postgres failed: {e}"); + Err(format!("error: {e}")) + } + }; + + // ── Redis: PING ─────────────────────────────────────────────────────────── + let redis = { + let mut conn = state.redis.clone(); + match redis::cmd("PING").query_async::(&mut conn).await { + Ok(_) => Ok("ok".to_owned()), + Err(e) => { + tracing::warn!("health check: redis failed: {e}"); + Err(format!("error: {e}")) + } + } + }; + + let healthy = postgres.is_ok() && redis.is_ok(); + let http_status = if healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + ( + http_status, + Json(HealthResponse { + status: if healthy { "healthy" } else { "degraded" }, + postgres: postgres.unwrap_or_else(|e| e), + redis: redis.unwrap_or_else(|e| e), + version: env!("CARGO_PKG_VERSION"), + uptime_secs, + }), + ) +} diff --git a/libs/shared/src/jwt.rs b/libs/shared/src/jwt.rs new file mode 100644 index 0000000..9df7bd9 --- /dev/null +++ b/libs/shared/src/jwt.rs @@ -0,0 +1,206 @@ +use axum::{ + extract::Request, + http::{HeaderValue, StatusCode, header}, + middleware::Next, + response::Response, +}; +use jsonwebtoken::{ + DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind, +}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const ACCESS_TOKEN_TYPE: &str = "access"; +const REFRESH_TOKEN_TYPE: &str = "refresh"; +const PENDING_TOKEN_TYPE: &str = "pending"; +const ACCESS_TOKEN_TTL_SECONDS: u64 = 60 * 15; // 15 minutes +const REFRESH_TOKEN_TTL_SECONDS: u64 = 60 * 60 * 24 * 30; // 30 days +const PENDING_TOKEN_TTL_SECONDS: u64 = 60 * 10; // 10 minutes +const COOKIE_ACCESS: &str = "access_token"; +const COOKIE_REFRESH: &str = "refresh_token"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, + pub exp: usize, + pub token_type: String, +} + +fn create_token( + user_id: Uuid, + token_type: &str, + ttl_seconds: u64, +) -> Result> { + let secret = std::env::var("JWT_SECRET")?; + let exp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + ttl_seconds; + let claims = Claims { sub: user_id, exp: exp as usize, token_type: token_type.to_string() }; + Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) +} + +pub fn create_access_token(user_id: Uuid) -> Result> { + create_token(user_id, ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TTL_SECONDS) +} + +pub fn create_refresh_token(user_id: Uuid) -> Result> { + create_token(user_id, REFRESH_TOKEN_TYPE, REFRESH_TOKEN_TTL_SECONDS) +} + +// --------------------------------------------------------------------------- +// Pending signup JWT — carries encrypted signup data until OTP is verified +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OtpClaims { + /// Identifies this as a pending-signup token, not an auth token. + pub token_type: String, + pub email: String, + pub username: String, + pub password_hash: String, + pub otp_hash: String, + pub exp: usize, +} + +/// Issue a short-lived JWT that holds the pending user's data + OTP hash. +/// The client stores this and sends it back with the OTP code. +pub fn create_pending_token( + email: &str, + username: &str, + password_hash: &str, + otp_hash: &str, +) -> Result> { + let secret = std::env::var("JWT_SECRET")?; + let exp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + PENDING_TOKEN_TTL_SECONDS; + let claims = OtpClaims { + token_type: PENDING_TOKEN_TYPE.to_string(), + email: email.to_string(), + username: username.to_string(), + password_hash: password_hash.to_string(), + otp_hash: otp_hash.to_string(), + exp: exp as usize, + }; + Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) +} + +/// Decode and validate a pending signup JWT. +/// Returns an error if the token is expired, tampered, or not a pending token. +pub fn decode_pending_token( + token: &str, +) -> Result> { + let secret = std::env::var("JWT_SECRET")?; + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + if data.claims.token_type != PENDING_TOKEN_TYPE { + return Err("invalid token type".into()); + } + Ok(data.claims) +} + +/// Parse a single cookie value from a `Cookie: a=1; b=2` header string. +fn parse_cookie(cookie_header: &str, name: &str) -> Option { + cookie_header.split(';').find_map(|pair| { + let pair = pair.trim(); + let (k, v) = pair.split_once('=')?; + (k.trim() == name).then(|| v.trim().to_owned()) + }) +} + +/// Append `Set-Cookie` headers for both tokens. +/// In debug builds `Secure` is omitted so plain-HTTP local testing works. +pub fn set_token_cookies( + response: &mut Response, + access_token: &str, + refresh_token: &str, +) -> Result<(), header::InvalidHeaderValue> { + #[cfg(debug_assertions)] + let flags = "HttpOnly; SameSite=Strict"; + #[cfg(not(debug_assertions))] + let flags = "HttpOnly; Secure; SameSite=Strict"; + + let access = format!("{COOKIE_ACCESS}={access_token}; {flags}; Max-Age={ACCESS_TOKEN_TTL_SECONDS}; Path=/"); + let refresh = format!("{COOKIE_REFRESH}={refresh_token}; {flags}; Max-Age={REFRESH_TOKEN_TTL_SECONDS}; Path=/"); + + response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&access)?); + response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&refresh)?); + Ok(()) +} + +/// Append `Set-Cookie` headers that instruct the browser to delete both cookies. +pub fn clear_token_cookies(response: &mut Response) { + for name in [COOKIE_ACCESS, COOKIE_REFRESH] { + let value = format!("{name}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/"); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&value).expect("static clear-cookie value is always valid"), + ); + } +} + +pub async fn jwt_auth(mut req: Request, next: Next) -> Result { + // Clone to String so we release the immutable borrow on `req` before mutating it. + let cookie_str = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)? + .to_owned(); + + let access_token = parse_cookie(&cookie_str, COOKIE_ACCESS).ok_or(StatusCode::UNAUTHORIZED)?; + + let secret = std::env::var("JWT_SECRET").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match decode::( + &access_token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + ) { + Ok(data) if data.claims.token_type == ACCESS_TOKEN_TYPE => { + req.extensions_mut().insert(data.claims); + Ok(next.run(req).await) + } + Ok(_) => Err(StatusCode::UNAUTHORIZED), + Err(err) if matches!(err.kind(), ErrorKind::ExpiredSignature) => { + refresh_access_token(req, next, &cookie_str, &secret).await + } + Err(_) => Err(StatusCode::UNAUTHORIZED), + } +} + +async fn refresh_access_token( + mut req: Request, + next: Next, + cookie_str: &str, + secret: &str, +) -> Result { + let refresh_token = + parse_cookie(cookie_str, COOKIE_REFRESH).ok_or(StatusCode::UNAUTHORIZED)?; + + let data = decode::( + &refresh_token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if data.claims.token_type != REFRESH_TOKEN_TYPE { + return Err(StatusCode::UNAUTHORIZED); + } + + let user_id = data.claims.sub; + let new_access = create_access_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let new_refresh = create_refresh_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + req.extensions_mut().insert(Claims { + sub: user_id, + exp: data.claims.exp, + token_type: ACCESS_TOKEN_TYPE.to_string(), + }); + + let mut response = next.run(req).await; + set_token_cookies(&mut response, &new_access, &new_refresh) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(response) +} diff --git a/libs/shared/src/lib.rs b/libs/shared/src/lib.rs new file mode 100644 index 0000000..52cfecc --- /dev/null +++ b/libs/shared/src/lib.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod error; +pub mod health; +pub mod jwt; +pub mod postgres; +pub mod redis; +pub mod state; diff --git a/libs/shared/src/postgres.rs b/libs/shared/src/postgres.rs new file mode 100644 index 0000000..9c6987f --- /dev/null +++ b/libs/shared/src/postgres.rs @@ -0,0 +1,10 @@ +use sqlx::{PgPool, postgres::PgPoolOptions}; + +pub type DbPool = PgPool; + +pub async fn init_pg_pool(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(5) + .connect(database_url) + .await +} diff --git a/libs/shared/src/redis.rs b/libs/shared/src/redis.rs new file mode 100644 index 0000000..b32ecaf --- /dev/null +++ b/libs/shared/src/redis.rs @@ -0,0 +1,22 @@ +use redis::{Client, RedisResult, aio::ConnectionManager}; + +/// Shared Redis handle for the entire application. +/// +/// `ConnectionManager` wraps a `MultiplexedConnection` and automatically +/// reconnects on network drops — essential for a distributed URL shortener +/// where Redis availability must be treated as a soft dependency. +/// +/// It is `Clone + Send + Sync`, so it can live in `Arc` and be +/// cloned cheaply into every Axum handler. +pub type RedisConn = ConnectionManager; + +/// Connect to Redis and return a `ConnectionManager`. +/// +/// Establishes one multiplexed TCP connection at startup and keeps it alive, +/// transparently reconnecting whenever the link is lost. +/// +/// Requires the `connection-manager` + `tokio-comp` feature flags. +pub async fn init_redis(url: &str) -> RedisResult { + let client = Client::open(url)?; + ConnectionManager::new(client).await +} diff --git a/libs/shared/src/state.rs b/libs/shared/src/state.rs new file mode 100644 index 0000000..ee850cf --- /dev/null +++ b/libs/shared/src/state.rs @@ -0,0 +1,21 @@ +use crate::redis::RedisConn; +use std::time::Instant; +use tokio::sync::mpsc::UnboundedSender; + +pub type ClickSender = UnboundedSender; + +pub struct AppState { + pub tx: ClickSender, + pub redis: RedisConn, + pub started_at: Instant, +} + +impl AppState { + pub fn new(tx: ClickSender, redis: RedisConn) -> Self { + Self { + tx, + redis, + started_at: Instant::now(), + } + } +} diff --git a/services/auth-service/.gitignore b/services/auth-service/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/services/auth-service/.gitignore @@ -0,0 +1 @@ +/target diff --git a/services/auth-service/Cargo.toml b/services/auth-service/Cargo.toml new file mode 100644 index 0000000..6b49f38 --- /dev/null +++ b/services/auth-service/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "auth-service" +version = "0.1.0" +edition = "2024" + +[dependencies] +shared = { path = "../../libs/shared" } +axum = "0.8.9" +uuid = { version = "1.23.2", features = ["serde", "v4"] } +rand = "0.9" +chrono = { version = "0.4.44", features = ["serde"] } +serde = { version = "1.0.228", features = ["derive"] } +validator = { version = "0.20", features = ["derive"] } +serde_json = "1" +lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } +bcrypt = "0.15" diff --git a/services/auth-service/src/debug_handler.rs b/services/auth-service/src/debug_handler.rs new file mode 100644 index 0000000..8c55271 --- /dev/null +++ b/services/auth-service/src/debug_handler.rs @@ -0,0 +1,8 @@ +use crate::otp_store; +use axum::{Json, response::IntoResponse}; + +/// GET /debug/otp-store +/// Returns all stored OTP entries. Only available when MODE=development. +pub async fn debug_otp_store_handler() -> impl IntoResponse { + Json(otp_store::get_all()) +} diff --git a/services/auth-service/src/email.rs b/services/auth-service/src/email.rs new file mode 100644 index 0000000..40c3b58 --- /dev/null +++ b/services/auth-service/src/email.rs @@ -0,0 +1,43 @@ +use crate::otp_store; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +type EmailResult = Result<(), Box>; + +/// Send a 6-digit OTP to `to_email`. +/// +/// Behaviour is controlled by the `MODE` environment variable: +/// - `development` → stores OTP in the in-memory DebugOtpStore (no real email sent) +/// - anything else → sends a real email via Gmail SMTP +pub async fn send_otp_email(to_email: &str, otp: &str) -> EmailResult { + let mode = std::env::var("MODE").unwrap_or_default(); + + if mode == "development" { + otp_store::store_otp(to_email, otp); + return Ok(()); + } + + // --- Production: real Gmail SMTP --- + let smtp_user = std::env::var("SMTP_USER")?; + let smtp_pass = std::env::var("SMTP_PASS")?; + + let email = Message::builder() + .from(format!("Bittuly <{smtp_user}>").parse()?) + .to(to_email.parse()?) + .subject("Your Bittuly verification code") + .header(ContentType::TEXT_PLAIN) + .body(format!( + "Your one-time verification code is: {otp}\n\nThis code expires in 10 minutes.\nIf you did not request this, please ignore this email." + ))?; + + let creds = Credentials::new(smtp_user, smtp_pass); + + let mailer = AsyncSmtpTransport::::relay("smtp.gmail.com")? + .credentials(creds) + .build(); + + mailer.send(email).await?; + + Ok(()) +} diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs new file mode 100644 index 0000000..8b64e2d --- /dev/null +++ b/services/auth-service/src/handlers.rs @@ -0,0 +1,190 @@ +use crate::models::{CreateUserPayload, LoginPayload, UpdateUserPayload, VerifyOtpPayload}; +use crate::service as user_service; +use axum::extract::{Extension, Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; +use shared::jwt::{Claims, clear_token_cookies, set_token_cookies}; +use shared::postgres::DbPool; +use uuid::Uuid; +use validator::Validate; + +pub async fn create_user( + State(db): State, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(errors) = payload.validate() { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); + } + match user_service::create_user(&db, payload).await { + Ok(auth) => { + let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); + if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { + tracing::error!("set_token_cookies: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + response + } + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), + } +} + +pub async fn login( + State(db): State, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(errors) = payload.validate() { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); + } + match user_service::login(&db, &payload.email, &payload.password).await { + Ok(auth) => { + let mut response = (StatusCode::OK, Json(auth.user)).into_response(); + if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { + tracing::error!("set_token_cookies: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + response + } + Err(_) => StatusCode::UNAUTHORIZED.into_response(), + } +} + +pub async fn logout() -> impl IntoResponse { + let mut response = StatusCode::NO_CONTENT.into_response(); + clear_token_cookies(&mut response); + response +} + +pub async fn get_user_by_id( + State(db): State, + Extension(claims): Extension, + Path(user_id): Path, +) -> impl IntoResponse { + let user_id = match Uuid::parse_str(&user_id) { + Ok(id) => id, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), + }; + + if user_id != claims.sub { + return StatusCode::FORBIDDEN.into_response(); + } + + match user_service::get_user_by_id(&db, user_id).await { + Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), + } +} + +pub async fn update_user( + State(db): State, + Extension(claims): Extension, + Path(user_id): Path, + Json(payload): Json, +) -> impl IntoResponse { + let user_id = match Uuid::parse_str(&user_id) { + Ok(id) => id, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), + }; + + if user_id != claims.sub { + return StatusCode::FORBIDDEN.into_response(); + } + + if let Err(errors) = payload.validate() { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); + } + + match user_service::update_user(&db, user_id, payload).await { + Ok(auth) => { + let mut response = (StatusCode::OK, Json(auth.user)).into_response(); + if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { + tracing::error!("set_token_cookies: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + response + } + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), + } +} + +pub async fn delete_user( + State(db): State, + Extension(claims): Extension, + Path(user_id): Path, +) -> impl IntoResponse { + let user_id = match Uuid::parse_str(&user_id) { + Ok(id) => id, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid user_id").into_response(), + }; + + if user_id != claims.sub { + return StatusCode::FORBIDDEN.into_response(); + } + + match user_service::delete_user(&db, user_id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), + } +} + +// --------------------------------------------------------------------------- +// OTP signup flow handlers +// --------------------------------------------------------------------------- + +/// POST /users/signup +/// Validates payload, sends OTP email, returns { pending_token } (no user created yet). +pub async fn request_signup_handler( + State(db): State, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(errors) = payload.validate() { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); + } + match user_service::request_signup(&db, payload).await { + Ok(pending_token) => ( + StatusCode::OK, + Json(json!({ "pending_token": pending_token })), + ) + .into_response(), + Err(err) if err.to_string() == "email already registered" => ( + StatusCode::CONFLICT, + Json(json!({ "error": err.to_string() })), + ) + .into_response(), + Err(err) => { + tracing::error!("request_signup: {:?}", err); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// POST /users/verify-otp +/// Verifies OTP, creates the real user, sets auth cookies, returns user. +pub async fn verify_otp_handler( + State(db): State, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(errors) = payload.validate() { + return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); + } + match user_service::verify_otp(&db, &payload.pending_token, &payload.otp).await { + Ok(auth) => { + let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); + if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { + tracing::error!("set_token_cookies: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + response + } + Err(err) if err.to_string() == "invalid OTP" => ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid or expired OTP" })), + ) + .into_response(), + Err(err) => { + tracing::error!("verify_otp: {:?}", err); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs new file mode 100644 index 0000000..7bd212f --- /dev/null +++ b/services/auth-service/src/main.rs @@ -0,0 +1,12 @@ +mod debug_handler; +mod email; +mod handlers; +mod models; +mod otp_store; +mod repository; +mod routes; +mod service; + +pub struct AuthState; + +fn main() {} diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs new file mode 100644 index 0000000..b4f50b7 --- /dev/null +++ b/services/auth-service/src/models.rs @@ -0,0 +1,60 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; +use validator::Validate; + +#[derive(sqlx::FromRow, Serialize)] +pub struct User { + pub id: Uuid, + pub username: String, + pub email: String, + #[serde(skip)] + pub password: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Serialize)] +pub struct AuthUserResponse { + pub user: User, + pub token: String, + pub refresh_token: String, +} + +#[derive(Deserialize, Validate)] +pub struct CreateUserPayload { + #[validate(length(min = 3, max = 50))] + pub username: String, + #[validate(email)] + pub email: String, + #[validate(length(min = 6))] + pub password: String, +} + +#[derive(Deserialize, Validate)] +pub struct UpdateUserPayload { + #[validate(length(min = 3, max = 50))] + pub username: Option, + #[validate(email)] + pub email: Option, + #[validate(length(min = 6))] + pub password: Option, +} + +#[derive(Deserialize, Validate)] +pub struct LoginPayload { + #[validate(email)] + pub email: String, + pub password: String, +} + +#[derive(Deserialize, Validate)] +pub struct VerifyOtpPayload { + /// The short-lived pending JWT issued by POST /users/signup. + pub pending_token: String, + /// The 6-digit OTP the user received by email. + #[validate(length(min = 6, max = 6))] + pub otp: String, +} diff --git a/services/auth-service/src/otp_store.rs b/services/auth-service/src/otp_store.rs new file mode 100644 index 0000000..f0c5d7d --- /dev/null +++ b/services/auth-service/src/otp_store.rs @@ -0,0 +1,48 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +/// A single OTP entry stored during development. +#[derive(Debug, Clone, Serialize)] +pub struct OtpEntry { + pub email: String, + pub otp: String, + pub created_at: DateTime, +} + +/// Global in-memory OTP store — only populated when MODE=development. +/// +/// Keyed by email so only the most recent OTP per address is kept. +/// Re-requesting a signup overwrites the previous entry automatically. +static STORE: OnceLock>> = OnceLock::new(); + +fn store() -> &'static RwLock> { + STORE.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Insert (or overwrite) the latest OTP for the given email. +/// Called by send_otp_email() in development mode. +pub fn store_otp(email: &str, otp: &str) { + let entry = OtpEntry { + email: email.to_string(), + otp: otp.to_string(), + created_at: Utc::now(), + }; + if let Ok(mut guard) = store().write() { + guard.insert(email.to_string(), entry); + } + tracing::info!("[DEV OTP] email={} otp={}", email, otp); +} + +/// Return all stored OTP entries, sorted by most recent first. +pub fn get_all() -> Vec { + let entries = store() + .read() + .map(|g| g.values().cloned().collect::>()) + .unwrap_or_default(); + + let mut sorted = entries; + sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + sorted +} diff --git a/services/auth-service/src/repository.rs b/services/auth-service/src/repository.rs new file mode 100644 index 0000000..c656da1 --- /dev/null +++ b/services/auth-service/src/repository.rs @@ -0,0 +1,70 @@ +use crate::models::{CreateUserPayload, UpdateUserPayload, User}; +use shared::postgres::DbPool; +use uuid::Uuid; + +pub async fn create_user(db: &DbPool, payload: CreateUserPayload) -> Result { + sqlx::query_as( + r#" + INSERT INTO users (id, username, email, password) + VALUES ($1, $2, $3, $4) + RETURNING id, username, email, password, created_at, updated_at + "#, + ) + .bind(Uuid::new_v4()) + .bind(&payload.username) + .bind(&payload.email) + .bind(&payload.password) + .fetch_one(db) + .await +} + +pub async fn get_user_by_id(db: &DbPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, username, email, password, created_at, updated_at FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(db) + .await +} + +pub async fn get_user_by_email(db: &DbPool, email: &str) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, username, email, password, created_at, updated_at FROM users WHERE email = $1", + ) + .bind(email) + .fetch_optional(db) + .await +} + +pub async fn update_user( + db: &DbPool, + user_id: Uuid, + payload: UpdateUserPayload, +) -> Result { + sqlx::query_as( + r#" + UPDATE users + SET + username = COALESCE($1, username), + email = COALESCE($2, email), + password = COALESCE($3, password), + updated_at = NOW() + WHERE id = $4 + RETURNING id, username, email, password, created_at, updated_at + "#, + ) + .bind(payload.username) + .bind(payload.email) + .bind(payload.password) + .bind(user_id) + .fetch_one(db) + .await +} + +pub async fn delete_user(db: &DbPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(db) + .await?; + Ok(()) +} diff --git a/services/auth-service/src/routes.rs b/services/auth-service/src/routes.rs new file mode 100644 index 0000000..4e3d5fa --- /dev/null +++ b/services/auth-service/src/routes.rs @@ -0,0 +1,24 @@ +use crate::handlers::{ + create_user, delete_user, get_user_by_id, login, logout, request_signup_handler, update_user, + verify_otp_handler, +}; +use axum::routing::{get, post}; +use axum::{Router, middleware}; +use shared::{jwt::jwt_auth, postgres::DbPool}; + +pub fn user_routes() -> Router { + let protected = Router::new() + .route( + "/{user_id}", + get(get_user_by_id).delete(delete_user).put(update_user), + ) + .route("/logout", post(logout)) + .layer(middleware::from_fn(jwt_auth)); + + Router::new() + .route("/signup", post(request_signup_handler)) // Step 1: send OTP + .route("/verify-otp", post(verify_otp_handler)) // Step 2: verify OTP → create user + JWT + .route("/direct-signup", post(create_user)) // Legacy: direct signup (no OTP) + .route("/login", post(login)) + .merge(protected) +} diff --git a/services/auth-service/src/service.rs b/services/auth-service/src/service.rs new file mode 100644 index 0000000..90428d6 --- /dev/null +++ b/services/auth-service/src/service.rs @@ -0,0 +1,148 @@ +use crate::email::send_otp_email; +use crate::models::{AuthUserResponse, CreateUserPayload, UpdateUserPayload, User}; +use crate::repository as user_repository; +use rand::Rng; +use shared::jwt::{ + create_access_token, create_pending_token, create_refresh_token, decode_pending_token, +}; +use shared::postgres::DbPool; +use uuid::Uuid; + +type ServiceResult = Result>; + +pub async fn create_user( + db: &DbPool, + mut payload: CreateUserPayload, +) -> ServiceResult { + payload.password = bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)?; + let user = user_repository::create_user(db, payload).await?; + let token = create_access_token(user.id)?; + let refresh_token = create_refresh_token(user.id)?; + + Ok(AuthUserResponse { + user, + token, + refresh_token, + }) +} + +// --------------------------------------------------------------------------- +// OTP signup flow +// --------------------------------------------------------------------------- + +/// Step 1 — validate + hash credentials, generate OTP, send email, return pending JWT. +/// +/// The pending JWT embeds { email, username, password_hash, otp_hash } and +/// expires in 10 minutes. No user row is created yet. +pub async fn request_signup(db: &DbPool, mut payload: CreateUserPayload) -> ServiceResult { + // Reject if email is already taken so we don't send an OTP pointlessly. + if user_repository::get_user_by_email(db, &payload.email) + .await? + .is_some() + { + return Err("email already registered".into()); + } + + // Hash the password before it goes anywhere near a JWT. + payload.password = bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST)?; + + // Generate a 6-digit OTP and bcrypt-hash it for the pending token. + let otp: String = rand::rng() + .random_range(100_000u32..=999_999u32) + .to_string(); + let otp_hash = bcrypt::hash(&otp, bcrypt::DEFAULT_COST)?; + + // Fire the email first — if it fails we return early without issuing a token. + send_otp_email(&payload.email, &otp).await?; + + // Embed all pending data into a short-lived signed JWT. + let pending_token = create_pending_token( + &payload.email, + &payload.username, + &payload.password, + &otp_hash, + )?; + + Ok(pending_token) +} + +/// Step 2 — verify the OTP, create the real user row, issue auth JWTs. +/// +/// `pending_token` is the JWT returned by `request_signup`. +/// `otp` is the 6-digit code the user received by email. +pub async fn verify_otp( + db: &DbPool, + pending_token: &str, + otp: &str, +) -> ServiceResult { + // Decode and verify the pending JWT (signature + expiry checked by the library). + let claims = decode_pending_token(pending_token)?; + + // Verify the submitted OTP against the hashed one inside the token. + if !bcrypt::verify(otp, &claims.otp_hash)? { + return Err("invalid OTP".into()); + } + + // OTP is valid — build the CreateUserPayload with the already-hashed password. + let payload = CreateUserPayload { + username: claims.username, + email: claims.email, + password: claims.password_hash, // already bcrypt-hashed in request_signup + }; + + let user = user_repository::create_user(db, payload).await?; + let token = create_access_token(user.id)?; + let refresh_token = create_refresh_token(user.id)?; + + Ok(AuthUserResponse { + user, + token, + refresh_token, + }) +} + +pub async fn login(db: &DbPool, email: &str, password: &str) -> ServiceResult { + let user: User = user_repository::get_user_by_email(db, email) + .await? + .ok_or("invalid credentials")?; + + if !bcrypt::verify(password, &user.password)? { + return Err("invalid credentials".into()); + } + + let token = create_access_token(user.id)?; + let refresh_token = create_refresh_token(user.id)?; + + Ok(AuthUserResponse { + user, + token, + refresh_token, + }) +} + +pub async fn get_user_by_id(db: &DbPool, user_id: Uuid) -> Result, sqlx::Error> { + user_repository::get_user_by_id(db, user_id).await +} + +pub async fn update_user( + db: &DbPool, + user_id: Uuid, + mut payload: UpdateUserPayload, +) -> ServiceResult { + if let Some(ref plain) = payload.password { + payload.password = Some(bcrypt::hash(plain, bcrypt::DEFAULT_COST)?); + } + let user = user_repository::update_user(db, user_id, payload).await?; + let token = create_access_token(user.id)?; + let refresh_token = create_refresh_token(user.id)?; + + Ok(AuthUserResponse { + user, + token, + refresh_token, + }) +} + +pub async fn delete_user(db: &DbPool, user_id: Uuid) -> Result<(), sqlx::Error> { + user_repository::delete_user(db, user_id).await +} From 10bfd4558c5ff56dec2d3086a222f92676cf105b Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 19:11:42 +0530 Subject: [PATCH 04/32] fix AuthState and importd --- Cargo.lock | 1 + main.rs | 120 ++++++++++++++++++++++++++ services/auth-service/Cargo.toml | 1 + services/auth-service/src/handlers.rs | 33 +++---- services/auth-service/src/main.rs | 18 +++- services/auth-service/src/models.rs | 9 ++ services/auth-service/src/routes.rs | 5 +- 7 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 main.rs diff --git a/Cargo.lock b/Cargo.lock index 8becee8..47f5add 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "serde_json", "shared", "sqlx", + "tokio", "tracing", "tracing-subscriber", "uuid", diff --git a/main.rs b/main.rs new file mode 100644 index 0000000..f63ac0a --- /dev/null +++ b/main.rs @@ -0,0 +1,120 @@ +mod app; +mod config; +mod db; +mod handlers; +mod middlewares; +mod models; +mod repository; +mod routes; +mod services; +mod utils; + +use std::{collections::HashMap, sync::Arc}; + +use app::state::AppState; +use config::settings::Settings; +use db::{postgres::init_pg_pool, redis::init_redis}; +use repository::url_repository; +use routes::router::create_router; +use tokio::{net::TcpListener, sync::mpsc}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("tower_http=debug,bittuly=info")), + ) + .init(); + + let settings = Settings::from_env().expect("failed to load settings"); + let db = init_pg_pool(&settings.database_url) + .await + .expect("failed to connect to database"); + let redis = init_redis(&settings.redis_url) + .await + .expect("failed to connect to redis"); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let app_state = Arc::new(AppState::new(tx.clone(), redis)); + + // Consumer task — two flush triggers: + // 1. Size : every 17 accumulated click events + // 2. Timer : every 30 seconds (so low-traffic links are never stuck) + let consumer_db = db.clone(); + let consumer_handler = tokio::spawn(async move { + let mut batch: HashMap = HashMap::new(); + let mut total_clicks: u64 = 0; + + let mut flush_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); + // Consume the immediate first tick so the timer starts 30 s from now, + // not from the moment the task spawns. + flush_interval.tick().await; + + loop { + tokio::select! { + // ── Arm 1: new click event from the channel ────────────────── + maybe_code = rx.recv() => { + match maybe_code { + Some(short_code) => { + *batch.entry(short_code).or_insert(0) += 1; + total_clicks += 1; + + if total_clicks >= 17 { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (size trigger)"), + Err(e) => tracing::error!("click batch flush failed: {e}"), + } + batch.clear(); + total_clicks = 0; + } + } + None => { + // Channel closed (server shutting down) — drain remainder + if !batch.is_empty() { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (shutdown drain)"), + Err(e) => tracing::error!("click batch final flush failed: {e}"), + } + } + break; + } + } + } + + // ── Arm 2: periodic 30-second flush ────────────────────────── + _ = flush_interval.tick() => { + if !batch.is_empty() { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (interval trigger)"), + Err(e) => tracing::error!("click batch interval flush failed: {e}"), + } + batch.clear(); + total_clicks = 0; + } + } + } + } + }); + + let app = create_router(db, &settings.mode, &settings.cors_origin, app_state); + let listener = TcpListener::bind(&settings.server_addr) + .await + .expect("failed to bind listener"); + + println!( + "listening on {} [mode={} cors={}]", + settings.server_addr, settings.mode, settings.cors_origin + ); + + if let Err(err) = axum::serve(listener, app).await { + eprintln!("server error: {err}"); + } + + // Signal consumer to stop and wait for it to drain + drop(tx); + consumer_handler.await.unwrap(); +} diff --git a/services/auth-service/Cargo.toml b/services/auth-service/Cargo.toml index 6b49f38..d9daa6d 100644 --- a/services/auth-service/Cargo.toml +++ b/services/auth-service/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } shared = { path = "../../libs/shared" } axum = "0.8.9" uuid = { version = "1.23.2", features = ["serde", "v4"] } diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index 8b64e2d..3c719d7 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -1,22 +1,23 @@ -use crate::models::{CreateUserPayload, LoginPayload, UpdateUserPayload, VerifyOtpPayload}; +use crate::models::{ + AuthStateRef, CreateUserPayload, LoginPayload, UpdateUserPayload, VerifyOtpPayload, +}; use crate::service as user_service; use axum::extract::{Extension, Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use serde_json::json; use shared::jwt::{Claims, clear_token_cookies, set_token_cookies}; -use shared::postgres::DbPool; use uuid::Uuid; use validator::Validate; pub async fn create_user( - State(db): State, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { if let Err(errors) = payload.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match user_service::create_user(&db, payload).await { + match user_service::create_user(&state.db, payload).await { Ok(auth) => { let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { @@ -30,13 +31,13 @@ pub async fn create_user( } pub async fn login( - State(db): State, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { if let Err(errors) = payload.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match user_service::login(&db, &payload.email, &payload.password).await { + match user_service::login(&state.db, &payload.email, &payload.password).await { Ok(auth) => { let mut response = (StatusCode::OK, Json(auth.user)).into_response(); if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { @@ -56,7 +57,7 @@ pub async fn logout() -> impl IntoResponse { } pub async fn get_user_by_id( - State(db): State, + State(state): State, Extension(claims): Extension, Path(user_id): Path, ) -> impl IntoResponse { @@ -69,7 +70,7 @@ pub async fn get_user_by_id( return StatusCode::FORBIDDEN.into_response(); } - match user_service::get_user_by_id(&db, user_id).await { + match user_service::get_user_by_id(&state.db, user_id).await { Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), @@ -77,7 +78,7 @@ pub async fn get_user_by_id( } pub async fn update_user( - State(db): State, + State(state): State, Extension(claims): Extension, Path(user_id): Path, Json(payload): Json, @@ -95,7 +96,7 @@ pub async fn update_user( return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match user_service::update_user(&db, user_id, payload).await { + match user_service::update_user(&state.db, user_id, payload).await { Ok(auth) => { let mut response = (StatusCode::OK, Json(auth.user)).into_response(); if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { @@ -109,7 +110,7 @@ pub async fn update_user( } pub async fn delete_user( - State(db): State, + State(state): State, Extension(claims): Extension, Path(user_id): Path, ) -> impl IntoResponse { @@ -122,7 +123,7 @@ pub async fn delete_user( return StatusCode::FORBIDDEN.into_response(); } - match user_service::delete_user(&db, user_id).await { + match user_service::delete_user(&state.db, user_id).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), } @@ -135,13 +136,13 @@ pub async fn delete_user( /// POST /users/signup /// Validates payload, sends OTP email, returns { pending_token } (no user created yet). pub async fn request_signup_handler( - State(db): State, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { if let Err(errors) = payload.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match user_service::request_signup(&db, payload).await { + match user_service::request_signup(&state.db, payload).await { Ok(pending_token) => ( StatusCode::OK, Json(json!({ "pending_token": pending_token })), @@ -162,13 +163,13 @@ pub async fn request_signup_handler( /// POST /users/verify-otp /// Verifies OTP, creates the real user, sets auth cookies, returns user. pub async fn verify_otp_handler( - State(db): State, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { if let Err(errors) = payload.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match user_service::verify_otp(&db, &payload.pending_token, &payload.otp).await { + match user_service::verify_otp(&state.db, &payload.pending_token, &payload.otp).await { Ok(auth) => { let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index 7bd212f..aa41481 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -7,6 +7,20 @@ mod repository; mod routes; mod service; -pub struct AuthState; +use std::sync::Arc; -fn main() {} +use shared::config; +use shared::postgres; + +#[tokio::main] +async fn main() { + let settings = config::Settings::from_env().expect("Failed to load setting from environment"); + let db = postgres::init_pg_pool(&settings.database_url) + .await + .expect("Failed to connect to Database"); + let auth_state = Arc::new(models::AuthState { db }); + let app = routes::user_routes().with_state(auth_state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index b4f50b7..b55df64 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -1,10 +1,19 @@ +use std::sync::Arc; + use chrono::DateTime; use chrono::Utc; use serde::Deserialize; use serde::Serialize; +use sqlx::PgPool; use uuid::Uuid; use validator::Validate; +pub struct AuthState { + pub db: PgPool, +} + +pub type AuthStateRef = Arc; + #[derive(sqlx::FromRow, Serialize)] pub struct User { pub id: Uuid, diff --git a/services/auth-service/src/routes.rs b/services/auth-service/src/routes.rs index 4e3d5fa..4f1052d 100644 --- a/services/auth-service/src/routes.rs +++ b/services/auth-service/src/routes.rs @@ -2,11 +2,12 @@ use crate::handlers::{ create_user, delete_user, get_user_by_id, login, logout, request_signup_handler, update_user, verify_otp_handler, }; +use crate::models::AuthStateRef; use axum::routing::{get, post}; use axum::{Router, middleware}; -use shared::{jwt::jwt_auth, postgres::DbPool}; +use shared::jwt::jwt_auth; -pub fn user_routes() -> Router { +pub fn user_routes() -> Router { let protected = Router::new() .route( "/{user_id}", From 17bfa77344cfece2142fbed54876f5f9963456eb Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 20:35:53 +0530 Subject: [PATCH 05/32] add consumer and impl url_service server --- libs/shared/src/config.rs | 56 +++++++++++++++++-------- services/auth-service/src/main.rs | 6 ++- services/url-service/src/consumer.rs | 62 ++++++++++++++++++++++++++++ services/url-service/src/handlers.rs | 23 +++++------ services/url-service/src/main.rs | 46 ++++++++++++++++++++- services/url-service/src/models.rs | 27 ++++++++++++ services/url-service/src/routes.rs | 9 ++-- 7 files changed, 192 insertions(+), 37 deletions(-) create mode 100644 services/url-service/src/consumer.rs diff --git a/libs/shared/src/config.rs b/libs/shared/src/config.rs index eeffbe7..2075d74 100644 --- a/libs/shared/src/config.rs +++ b/libs/shared/src/config.rs @@ -1,34 +1,56 @@ use std::env; -pub struct Settings { +pub struct AuthConfig { + pub database_url: String, + pub server_port: u16, + pub mode: String, + pub server_addr: String, + pub cors_origin: String, +} + +pub struct UrlConfig { pub database_url: String, pub redis_url: String, + pub server_port: u16, pub server_addr: String, - /// "development" | "production" pub mode: String, - /// Allowed CORS origin, e.g. "http://localhost:5173" pub cors_origin: String, } -impl Settings { +impl AuthConfig { pub fn from_env() -> Result { dotenvy::dotenv().ok(); - let database_url = env::var("DATABASE_URL")?; - let redis_url = - env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); - let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_owned()); - let port = env::var("PORT").unwrap_or_else(|_| "3000".to_owned()); - let mode = env::var("MODE").unwrap_or_else(|_| "production".to_owned()); - let cors_origin = - env::var("CORS_ORIGIN").unwrap_or_else(|_| "http://localhost:5173".to_owned()); + Ok(Self { + database_url: env::var("AUTH_DATABASE_URL")?, + server_port: env::var("AUTH_PORT") + .unwrap_or_else(|_| "3001".to_string()) + .parse() + .expect("AUTH_PORT must be a number"), + server_addr: env::var("AUTH_ADDR").unwrap_or_else(|_| "0.0.0.0".to_owned()), + mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), + cors_origin: env::var("CORS_ORIGIN") + .unwrap_or_else(|_| "http://localhost:5173".to_owned()), + }) + } +} + +impl UrlConfig { + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); Ok(Self { - database_url, - redis_url, - server_addr: format!("{host}:{port}"), - mode, - cors_origin, + database_url: env::var("URL_DATABASE_URL")?, + redis_url: env::var("REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()), + server_port: env::var("URL_PORT") + .unwrap_or_else(|_| "3002".to_string()) + .parse() + .expect("URL_PORT must be a number"), + server_addr: env::var("URL_ADDR").unwrap_or_else(|_| "0.0.0.0".to_owned()), + mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), + cors_origin: env::var("CORS_ORIGIN") + .unwrap_or_else(|_| "http://localhost:5173".to_owned()), }) } } diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index aa41481..3cb2b71 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -14,13 +14,15 @@ use shared::postgres; #[tokio::main] async fn main() { - let settings = config::Settings::from_env().expect("Failed to load setting from environment"); + let settings = config::AuthConfig::from_env().expect("Failed to load setting from environment"); let db = postgres::init_pg_pool(&settings.database_url) .await .expect("Failed to connect to Database"); let auth_state = Arc::new(models::AuthState { db }); let app = routes::user_routes().with_state(auth_state); - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", settings.server_port)) + .await + .unwrap(); axum::serve(listener, app).await.unwrap(); } diff --git a/services/url-service/src/consumer.rs b/services/url-service/src/consumer.rs new file mode 100644 index 0000000..cb198ce --- /dev/null +++ b/services/url-service/src/consumer.rs @@ -0,0 +1,62 @@ +use crate::repository as url_repository; +use shared::postgres::DbPool; +use std::collections::HashMap; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::task::JoinHandle; + +pub fn spawn_consumer(mut rx: UnboundedReceiver, consumer_db: DbPool) -> JoinHandle<()> { + tokio::spawn(async move { + let mut batch: HashMap = HashMap::new(); + let mut total_clicks: u64 = 0; + + let mut flush_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); + // Consume the immediate first tick so the timer starts 30 s from now, + // not from the moment the task spawns. + flush_interval.tick().await; + + loop { + tokio::select! { + // ── Arm 1: new click event from the channel ────────────────── + maybe_code = rx.recv() => { + match maybe_code { + Some(short_code) => { + *batch.entry(short_code).or_insert(0) += 1; + total_clicks += 1; + + if total_clicks >= 17 { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (size trigger)"), + Err(e) => tracing::error!("click batch flush failed: {e}"), + } + batch.clear(); + total_clicks = 0; + } + } + None => { + // Channel closed (server shutting down) — drain remainder + if !batch.is_empty() { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (shutdown drain)"), + Err(e) => tracing::error!("click batch final flush failed: {e}"), + } + } + break; + } + } + } + + // ── Arm 2: periodic 30-second flush ────────────────────────── + _ = flush_interval.tick() => { + if !batch.is_empty() { + match url_repository::increment_click_counts(&consumer_db, &batch).await { + Ok(()) => tracing::info!(total_clicks, "click batch flushed (interval trigger)"), + Err(e) => tracing::error!("click batch interval flush failed: {e}"), + } + batch.clear(); + total_clicks = 0; + } + } + } + } + }) +} diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 32cbd02..5b2b0c1 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -1,4 +1,4 @@ -use crate::services as url_service; +use crate::{models::UrlStateRef, services as url_service}; use axum::{ extract::{Extension, Json, Path, Query, State}, http::StatusCode, @@ -6,8 +6,7 @@ use axum::{ }; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; -use shared::{jwt::Claims, postgres::DbPool, state::AppState}; -use std::sync::Arc; +use shared::jwt::Claims; use validator::Validate; const DEFAULT_PAGE_LIMIT: i64 = 20; @@ -29,7 +28,7 @@ pub struct UrlsPageResponse { } pub async fn get_all_urls( - State(db): State, + State(state): State, Extension(claims): Extension, Query(params): Query, ) -> impl IntoResponse { @@ -51,7 +50,7 @@ pub async fn get_all_urls( let limit = params.limit.unwrap_or(DEFAULT_PAGE_LIMIT); let search = params.search.filter(|s| !s.trim().is_empty()); - match url_service::get_urls_page(&db, claims.sub, cursor, limit, search).await { + match url_service::get_urls_page(&state.db, claims.sub, cursor, limit, search).await { Ok(page) => ( StatusCode::OK, Json(UrlsPageResponse { @@ -74,14 +73,14 @@ pub struct ShortenUrlRequest { } pub async fn shorten_url( - State(db): State, + State(state): State, Extension(claims): Extension, Json(body): Json, ) -> impl IntoResponse { if let Err(errors) = body.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match url_service::shorten_url(&db, &body.original_url, claims.sub).await { + match url_service::shorten_url(&state.db, &body.original_url, claims.sub).await { Ok(Some(url)) => (StatusCode::CREATED, Json(url)).into_response(), Ok(None) => ( StatusCode::CONFLICT, @@ -96,8 +95,7 @@ pub async fn shorten_url( } pub async fn get_original_url( - State(db): State, - Extension(state): Extension>, + State(state): State, Path(short_code): Path, ) -> impl IntoResponse { let mut redis = state.redis.clone(); @@ -122,7 +120,7 @@ pub async fn get_original_url( // ── 2. Cache miss — query DB ─────────────────────────────────────────── tracing::info!(short_code, "cache miss"); - match url_service::get_original_url(&db, &short_code).await { + match url_service::get_original_url(&state.db, &short_code).await { Ok(Some(original_url)) => { // Populate cache with 24 h TTL, non-fatal if Redis is down if let Err(e) = redis @@ -146,12 +144,11 @@ pub async fn get_original_url( } pub async fn delete_url_handler( - State(db): State, - Extension(state): Extension>, + State(state): State, Extension(claims): Extension, Path(url_id): Path, ) -> impl IntoResponse { - match url_service::delete_url(&db, url_id, claims.sub).await { + match url_service::delete_url(&state.db, url_id, claims.sub).await { Ok(Some(short_code)) => { // Evict from Redis cache — non-fatal if Redis is unavailable let mut redis = state.redis.clone(); diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index 67b0188..a165138 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -1,9 +1,51 @@ +mod consumer; mod handlers; mod models; mod repository; mod routes; mod services; -pub struct UrlStruct; +use shared::config; +use shared::postgres; +use shared::redis; +use std::sync::Arc; -fn main() {} +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() { + let settings = config::UrlConfig::from_env().expect("Failed to load setting from environment"); + let db = postgres::init_pg_pool(&settings.database_url) + .await + .expect("Failed to connect to Database"); + let redis = redis::init_redis(&settings.redis_url) + .await + .expect("Failed to connect to Redis"); + + // Consumer task — two flush triggers: + // 1. Size : every 17 accumulated click events + // 2. Timer : every 30 seconds (so low-traffic links are never stuck) + let consumer_db = db.clone(); + let (tx, rx) = mpsc::unbounded_channel::(); + let consumer_handler = consumer::spawn_consumer(rx, consumer_db); + let url_state = Arc::new(models::UrlState::new(tx.clone(), redis, db)); + let app = routes::url_routes().with_state(url_state); + + let listener = + tokio::net::TcpListener::bind(format!("{}:{}", settings.server_addr, settings.server_port)) + .await + .expect("failed to bind listener"); + + println!( + "listening on {} [mode={} cors={}]", + settings.server_addr, settings.mode, settings.cors_origin + ); + + if let Err(err) = axum::serve(listener, app).await { + eprintln!("server error: {err}"); + } + + // Signal consumer to stop and wait for it to drain + drop(tx); + consumer_handler.await.unwrap(); +} diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index a5803b1..904f35f 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -1,8 +1,35 @@ +use std::sync::Arc; + use chrono::DateTime; use chrono::Utc; use serde::Serialize; +use shared::redis::RedisConn; +use sqlx::PgPool; +use tokio::sync::mpsc::UnboundedSender; +use tokio::time::Instant; use uuid::Uuid; +pub type ClickSender = UnboundedSender; + +pub struct UrlState { + pub db: PgPool, + pub tx: ClickSender, + pub redis: RedisConn, + pub started_at: Instant, +} + +impl UrlState { + pub fn new(tx: ClickSender, redis: RedisConn, db: PgPool) -> Self { + Self { + db, + tx, + redis, + started_at: Instant::now(), + } + } +} +pub type UrlStateRef = Arc; + #[derive(sqlx::FromRow, Serialize)] pub struct Url { #[serde(rename = "id")] diff --git a/services/url-service/src/routes.rs b/services/url-service/src/routes.rs index 687e48c..f9a39f3 100644 --- a/services/url-service/src/routes.rs +++ b/services/url-service/src/routes.rs @@ -5,10 +5,13 @@ use axum::{ routing::{get, post}, }; -use crate::handlers::{delete_url_handler, get_all_urls, get_original_url, shorten_url}; -use shared::{jwt::jwt_auth, postgres::DbPool}; +use crate::{ + handlers::{delete_url_handler, get_all_urls, get_original_url, shorten_url}, + models::UrlStateRef, +}; +use shared::jwt::jwt_auth; -pub fn url_routes() -> Router { +pub fn url_routes() -> Router { // POST / and GET / both require auth let protected_root = Router::new() .route("/", post(shorten_url).get(get_all_urls)) From d51dba758f33825359dbcc133485dc15d06102b0 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 20:48:25 +0530 Subject: [PATCH 06/32] impl aith_service runnable --- services/auth-service/src/debug_handler.rs | 1 + services/auth-service/src/handlers.rs | 2 +- services/auth-service/src/main.rs | 27 ++++++++++++------- services/auth-service/src/models.rs | 6 +++++ services/auth-service/src/otp_store.rs | 1 + services/auth-service/src/routes.rs | 2 +- .../src/{service.rs => services.rs} | 0 7 files changed, 28 insertions(+), 11 deletions(-) rename services/auth-service/src/{service.rs => services.rs} (100%) diff --git a/services/auth-service/src/debug_handler.rs b/services/auth-service/src/debug_handler.rs index 8c55271..70a5186 100644 --- a/services/auth-service/src/debug_handler.rs +++ b/services/auth-service/src/debug_handler.rs @@ -3,6 +3,7 @@ use axum::{Json, response::IntoResponse}; /// GET /debug/otp-store /// Returns all stored OTP entries. Only available when MODE=development. +#[allow(unused)] pub async fn debug_otp_store_handler() -> impl IntoResponse { Json(otp_store::get_all()) } diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index 3c719d7..943f105 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -1,7 +1,7 @@ use crate::models::{ AuthStateRef, CreateUserPayload, LoginPayload, UpdateUserPayload, VerifyOtpPayload, }; -use crate::service as user_service; +use crate::services as user_service; use axum::extract::{Extension, Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index 3cb2b71..e73ef9e 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -5,12 +5,11 @@ mod models; mod otp_store; mod repository; mod routes; -mod service; - -use std::sync::Arc; +mod services; use shared::config; use shared::postgres; +use std::sync::Arc; #[tokio::main] async fn main() { @@ -18,11 +17,21 @@ async fn main() { let db = postgres::init_pg_pool(&settings.database_url) .await .expect("Failed to connect to Database"); - let auth_state = Arc::new(models::AuthState { db }); - let app = routes::user_routes().with_state(auth_state); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", settings.server_port)) - .await - .unwrap(); - axum::serve(listener, app).await.unwrap(); + let auth_state = Arc::new(models::AuthState::new(db)); + let app = routes::auth_routes().with_state(auth_state); + + let listener = + tokio::net::TcpListener::bind(format!("{}:{}", settings.server_addr, settings.server_port)) + .await + .expect("failed to bind listener"); + + println!( + "Auth service listening on {} [mode={} cors={}]", + settings.server_addr, settings.mode, settings.cors_origin + ); + + if let Err(err) = axum::serve(listener, app).await { + eprintln!("Auth server error: {err}"); + } } diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index b55df64..899c24b 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -12,6 +12,12 @@ pub struct AuthState { pub db: PgPool, } +impl AuthState { + pub fn new(db: PgPool) -> Self { + Self { db } + } +} + pub type AuthStateRef = Arc; #[derive(sqlx::FromRow, Serialize)] diff --git a/services/auth-service/src/otp_store.rs b/services/auth-service/src/otp_store.rs index f0c5d7d..884502f 100644 --- a/services/auth-service/src/otp_store.rs +++ b/services/auth-service/src/otp_store.rs @@ -36,6 +36,7 @@ pub fn store_otp(email: &str, otp: &str) { } /// Return all stored OTP entries, sorted by most recent first. +#[allow(unused)] pub fn get_all() -> Vec { let entries = store() .read() diff --git a/services/auth-service/src/routes.rs b/services/auth-service/src/routes.rs index 4f1052d..bd7f275 100644 --- a/services/auth-service/src/routes.rs +++ b/services/auth-service/src/routes.rs @@ -7,7 +7,7 @@ use axum::routing::{get, post}; use axum::{Router, middleware}; use shared::jwt::jwt_auth; -pub fn user_routes() -> Router { +pub fn auth_routes() -> Router { let protected = Router::new() .route( "/{user_id}", diff --git a/services/auth-service/src/service.rs b/services/auth-service/src/services.rs similarity index 100% rename from services/auth-service/src/service.rs rename to services/auth-service/src/services.rs From 898824cfab0427963b9a9dc3eff6304e2646e008 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 20:50:06 +0530 Subject: [PATCH 07/32] remove monolith main.rs --- main.rs | 120 -------------------------------------------------------- 1 file changed, 120 deletions(-) delete mode 100644 main.rs diff --git a/main.rs b/main.rs deleted file mode 100644 index f63ac0a..0000000 --- a/main.rs +++ /dev/null @@ -1,120 +0,0 @@ -mod app; -mod config; -mod db; -mod handlers; -mod middlewares; -mod models; -mod repository; -mod routes; -mod services; -mod utils; - -use std::{collections::HashMap, sync::Arc}; - -use app::state::AppState; -use config::settings::Settings; -use db::{postgres::init_pg_pool, redis::init_redis}; -use repository::url_repository; -use routes::router::create_router; -use tokio::{net::TcpListener, sync::mpsc}; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("tower_http=debug,bittuly=info")), - ) - .init(); - - let settings = Settings::from_env().expect("failed to load settings"); - let db = init_pg_pool(&settings.database_url) - .await - .expect("failed to connect to database"); - let redis = init_redis(&settings.redis_url) - .await - .expect("failed to connect to redis"); - - let (tx, mut rx) = mpsc::unbounded_channel::(); - let app_state = Arc::new(AppState::new(tx.clone(), redis)); - - // Consumer task — two flush triggers: - // 1. Size : every 17 accumulated click events - // 2. Timer : every 30 seconds (so low-traffic links are never stuck) - let consumer_db = db.clone(); - let consumer_handler = tokio::spawn(async move { - let mut batch: HashMap = HashMap::new(); - let mut total_clicks: u64 = 0; - - let mut flush_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); - // Consume the immediate first tick so the timer starts 30 s from now, - // not from the moment the task spawns. - flush_interval.tick().await; - - loop { - tokio::select! { - // ── Arm 1: new click event from the channel ────────────────── - maybe_code = rx.recv() => { - match maybe_code { - Some(short_code) => { - *batch.entry(short_code).or_insert(0) += 1; - total_clicks += 1; - - if total_clicks >= 17 { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (size trigger)"), - Err(e) => tracing::error!("click batch flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - None => { - // Channel closed (server shutting down) — drain remainder - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (shutdown drain)"), - Err(e) => tracing::error!("click batch final flush failed: {e}"), - } - } - break; - } - } - } - - // ── Arm 2: periodic 30-second flush ────────────────────────── - _ = flush_interval.tick() => { - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (interval trigger)"), - Err(e) => tracing::error!("click batch interval flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - } - } - }); - - let app = create_router(db, &settings.mode, &settings.cors_origin, app_state); - let listener = TcpListener::bind(&settings.server_addr) - .await - .expect("failed to bind listener"); - - println!( - "listening on {} [mode={} cors={}]", - settings.server_addr, settings.mode, settings.cors_origin - ); - - if let Err(err) = axum::serve(listener, app).await { - eprintln!("server error: {err}"); - } - - // Signal consumer to stop and wait for it to drain - drop(tx); - consumer_handler.await.unwrap(); -} From 39078e5a2a5296ab706fb96294e77075e639ded0 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:08:11 +0530 Subject: [PATCH 08/32] fix health checks separation and updated docker-compose --- .env.example | 13 +++++ Cargo.lock | 3 -- docker-compose.yml | 33 +++++++++--- docker/postgres-urls/init/01-init.sql | 2 +- libs/shared/src/config.rs | 4 +- libs/shared/src/error.rs | 0 libs/shared/src/lib.rs | 3 -- libs/shared/src/state.rs | 21 -------- services/auth-service/src/health.rs | 50 +++++++++++++++++++ services/auth-service/src/main.rs | 1 + services/auth-service/src/models.rs | 7 ++- services/url-service/Cargo.toml | 3 -- .../url-service}/src/health.rs | 19 ++----- services/url-service/src/main.rs | 1 + 14 files changed, 105 insertions(+), 55 deletions(-) create mode 100644 .env.example delete mode 100644 libs/shared/src/error.rs delete mode 100644 libs/shared/src/state.rs create mode 100644 services/auth-service/src/health.rs rename {libs/shared => services/url-service}/src/health.rs (84%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a1151f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +MODE=development +CORS_ORIGIN=http://localhost:5173 +AUTH_DATABASE_URL="postgres://bittu:bittu@localhost:5432/bittuly_auth" +URL_DATABASE_URL="postgres://bittu:bittu@localhost:5433/bittuly_urls" +REDIS_URL="redis://127.0.0.1:6379" +AUTH_HOST="0.0.0.0" +URL_HOST="0.0.0.0" +AUTH_PORT=3001 +URL_PORT=3002 +RUST_LOG=tower_http=debug,bittuly=info +JWT_SECRET="this-is-a-production-distributed-url-shortener" +SMTP_USER="xxxxx@gmail.com" +SMTP_PASS="xxxx xxxx xxxx xxxx" diff --git a/Cargo.lock b/Cargo.lock index 47f5add..baffda5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2707,10 +2707,7 @@ version = "0.1.0" dependencies = [ "axum", "base62", - "bcrypt", "chrono", - "dotenvy", - "jsonwebtoken", "redis", "serde", "serde_json", diff --git a/docker-compose.yml b/docker-compose.yml index e23994b..b16ffab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,36 @@ services: - postgres: + postgres-auth: image: postgres:17 - container_name: bittuly-postgres + container_name: bittuly-postgres-auth environment: POSTGRES_USER: bittu POSTGRES_PASSWORD: bittu - POSTGRES_DB: bittuly + POSTGRES_DB: bittuly_auth ports: - "5432:5432" volumes: - - bittuly-postgres-data:/var/lib/postgresql/data - - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro + - bittuly-pg-auth-data:/var/lib/postgresql/data + - ./docker/postgres_auth/init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U bittu -d bittuly"] + test: ["CMD-SHELL", "pg_isready -U bittu -d bittuly_auth"] + interval: 5s + timeout: 5s + retries: 5 + + postgres-urls: + image: postgres:17 + container_name: bittuly-postgres-urls + environment: + POSTGRES_USER: bittu + POSTGRES_PASSWORD: bittu + POSTGRES_DB: bittuly_urls + ports: + - "5433:5432" + volumes: + - bittuly-pg-urls-data:/var/lib/postgresql/data + - ./docker/postgres_urls/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bittu -d bittuly_urls"] interval: 5s timeout: 5s retries: 5 @@ -34,4 +52,5 @@ services: retries: 5 volumes: - bittuly-postgres-data: + bittuly-pg-auth-data: + bittuly-pg-urls-data: diff --git a/docker/postgres-urls/init/01-init.sql b/docker/postgres-urls/init/01-init.sql index da263d6..efceabd 100644 --- a/docker/postgres-urls/init/01-init.sql +++ b/docker/postgres-urls/init/01-init.sql @@ -12,6 +12,6 @@ CREATE TABLE IF NOT EXISTS urls ( UNIQUE(original_url, user_id) ); -CREATE INDEX IF NOT EXISTS idx_urls_user_id ON urls(user_id); +CREATE INDEX IF NOT EXISTS idx_urls_user_id ON urls(user_id, url_id DESC); CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code); CREATE INDEX IF NOT EXISTS idx_urls_original_url_trgm ON urls USING GIN (original_url gin_trgm_ops); diff --git a/libs/shared/src/config.rs b/libs/shared/src/config.rs index 2075d74..539d92a 100644 --- a/libs/shared/src/config.rs +++ b/libs/shared/src/config.rs @@ -27,7 +27,7 @@ impl AuthConfig { .unwrap_or_else(|_| "3001".to_string()) .parse() .expect("AUTH_PORT must be a number"), - server_addr: env::var("AUTH_ADDR").unwrap_or_else(|_| "0.0.0.0".to_owned()), + server_addr: env::var("AUTH_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()), mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), cors_origin: env::var("CORS_ORIGIN") .unwrap_or_else(|_| "http://localhost:5173".to_owned()), @@ -47,7 +47,7 @@ impl UrlConfig { .unwrap_or_else(|_| "3002".to_string()) .parse() .expect("URL_PORT must be a number"), - server_addr: env::var("URL_ADDR").unwrap_or_else(|_| "0.0.0.0".to_owned()), + server_addr: env::var("URL_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()), mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), cors_origin: env::var("CORS_ORIGIN") .unwrap_or_else(|_| "http://localhost:5173".to_owned()), diff --git a/libs/shared/src/error.rs b/libs/shared/src/error.rs deleted file mode 100644 index e69de29..0000000 diff --git a/libs/shared/src/lib.rs b/libs/shared/src/lib.rs index 52cfecc..9a82c6e 100644 --- a/libs/shared/src/lib.rs +++ b/libs/shared/src/lib.rs @@ -1,7 +1,4 @@ pub mod config; -pub mod error; -pub mod health; pub mod jwt; pub mod postgres; pub mod redis; -pub mod state; diff --git a/libs/shared/src/state.rs b/libs/shared/src/state.rs deleted file mode 100644 index ee850cf..0000000 --- a/libs/shared/src/state.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::redis::RedisConn; -use std::time::Instant; -use tokio::sync::mpsc::UnboundedSender; - -pub type ClickSender = UnboundedSender; - -pub struct AppState { - pub tx: ClickSender, - pub redis: RedisConn, - pub started_at: Instant, -} - -impl AppState { - pub fn new(tx: ClickSender, redis: RedisConn) -> Self { - Self { - tx, - redis, - started_at: Instant::now(), - } - } -} diff --git a/services/auth-service/src/health.rs b/services/auth-service/src/health.rs new file mode 100644 index 0000000..f7c9ec4 --- /dev/null +++ b/services/auth-service/src/health.rs @@ -0,0 +1,50 @@ +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use serde::Serialize; + +use crate::models::AuthStateRef; + +#[allow(unused)] +#[derive(Serialize)] +pub struct HealthResponse { + /// "healthy" if all checks pass, "degraded" if any fail. + pub status: &'static str, + pub postgres: String, + /// Crate version from Cargo.toml at compile time. + pub version: &'static str, + /// Seconds since the server started. + pub uptime_secs: u64, +} + +/// `GET /health` +/// +/// Public endpoint — no authentication required. +/// Returns 200 when all dependencies are reachable, 503 otherwise. +pub async fn health(State(state): State) -> impl IntoResponse { + let uptime_secs = state.started_at.elapsed().as_secs(); + + // ── Postgres: send a trivial query ─────────────────────────────────────── + let postgres = match sqlx::query("SELECT 1").execute(&state.db).await { + Ok(_) => Ok("ok".to_owned()), + Err(e) => { + tracing::warn!("health check: postgres failed: {e}"); + Err(format!("error: {e}")) + } + }; + + let healthy = postgres.is_ok(); + let http_status = if healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + ( + http_status, + Json(HealthResponse { + status: if healthy { "healthy" } else { "degraded" }, + postgres: postgres.unwrap_or_else(|e| e), + version: env!("CARGO_PKG_VERSION"), + uptime_secs, + }), + ) +} diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index e73ef9e..526cdff 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -1,6 +1,7 @@ mod debug_handler; mod email; mod handlers; +mod health; mod models; mod otp_store; mod repository; diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index 899c24b..4d7b557 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -5,16 +5,21 @@ use chrono::Utc; use serde::Deserialize; use serde::Serialize; use sqlx::PgPool; +use tokio::time::Instant; use uuid::Uuid; use validator::Validate; pub struct AuthState { pub db: PgPool, + pub started_at: Instant, } impl AuthState { pub fn new(db: PgPool) -> Self { - Self { db } + Self { + db, + started_at: Instant::now(), + } } } diff --git a/services/url-service/Cargo.toml b/services/url-service/Cargo.toml index b2b71fa..03b86a0 100644 --- a/services/url-service/Cargo.toml +++ b/services/url-service/Cargo.toml @@ -9,8 +9,6 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } uuid = { version = "1.23.2", features = ["serde", "v4"] } redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } -dotenvy = "0.15.7" -jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1" sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } @@ -18,6 +16,5 @@ tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } tower-http = { version = "0.6.11", features = ["cors", "trace"] } axum = "0.8.9" base62 = "2.2.4" -bcrypt = "0.15" validator = { version = "0.20", features = ["derive"] } chrono = { version = "0.4.44", features = ["serde"] } diff --git a/libs/shared/src/health.rs b/services/url-service/src/health.rs similarity index 84% rename from libs/shared/src/health.rs rename to services/url-service/src/health.rs index 15678a1..492a4f8 100644 --- a/libs/shared/src/health.rs +++ b/services/url-service/src/health.rs @@ -1,15 +1,9 @@ -use std::sync::Arc; - -use axum::{ - Json, - extract::{Extension, State}, - http::StatusCode, - response::IntoResponse, -}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde::Serialize; -use crate::{postgres::DbPool, state::AppState}; +use crate::models::UrlStateRef; +#[allow(unused)] #[derive(Serialize)] pub struct HealthResponse { /// "healthy" if all checks pass, "degraded" if any fail. @@ -26,14 +20,11 @@ pub struct HealthResponse { /// /// Public endpoint — no authentication required. /// Returns 200 when all dependencies are reachable, 503 otherwise. -pub async fn health( - State(db): State, - Extension(state): Extension>, -) -> impl IntoResponse { +pub async fn health(State(state): State) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); // ── Postgres: send a trivial query ─────────────────────────────────────── - let postgres = match sqlx::query("SELECT 1").execute(&db).await { + let postgres = match sqlx::query("SELECT 1").execute(&state.db).await { Ok(_) => Ok("ok".to_owned()), Err(e) => { tracing::warn!("health check: postgres failed: {e}"); diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index a165138..2e73ebf 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -1,5 +1,6 @@ mod consumer; mod handlers; +mod health; mod models; mod repository; mod routes; From fc9de6d2f3794eadf3acf551c428a57930d0fe26 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:14:45 +0530 Subject: [PATCH 09/32] max TARGET with reality of the project and fix docker-volume name mismatch --- TARGET.md | 14 +++----------- docker-compose.yml | 4 ++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/TARGET.md b/TARGET.md index 23edb78..0bb3c73 100644 --- a/TARGET.md +++ b/TARGET.md @@ -66,7 +66,7 @@ Internet (HTTPS only) ┌──────────────┐ ┌──────────────────┐ │ pg-auth │ │ pg-urls │ │ Postgres 17 │ │ Postgres 17 │ - │ users, otps │ │ urls │ + │ users │ │ urls │ └──────────────┘ └──────────────────┘ ┌────────────────────────┐ ┌──────────────────────────┐ @@ -89,7 +89,7 @@ Internet (HTTPS only) | Property | Value | |---|---| -| Database | `pg-auth` — tables: `users`, `otps` | +| Database | `pg-auth` — tables: `users` | | Replicas | min 2, max 5 (HPA on CPU > 70%) | | CPU | 100m request / 500m limit | | Memory | 64 Mi request / 256 Mi limit | @@ -268,14 +268,6 @@ CREATE TABLE users ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - -CREATE TABLE otps ( - otp_id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - code VARCHAR(6) NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - used BOOLEAN NOT NULL DEFAULT FALSE -); ``` ### pg-urls @@ -528,7 +520,7 @@ Each service exposes `GET /metrics` using the `metrics` + `metrics-exporter-prom ### Phase 1 — Service Extraction & DB Split **Goal:** Two fully independent services with separate databases. -- [ ] `pg-auth`: users + otps tables only +- [ ] `pg-auth`: users table only - [ ] `pg-urls`: urls table only, `user_id` is a plain UUID column (no FK) - [ ] `auth-service`: issues JWTs; validates via `/api/auth/validate` - [ ] `url-service`: reads `X-User-Id` header injected by gateway (no JWT parsing) diff --git a/docker-compose.yml b/docker-compose.yml index b16ffab..feeb4ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: - "5432:5432" volumes: - bittuly-pg-auth-data:/var/lib/postgresql/data - - ./docker/postgres_auth/init:/docker-entrypoint-initdb.d:ro + - ./docker/postgres-auth/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U bittu -d bittuly_auth"] interval: 5s @@ -28,7 +28,7 @@ services: - "5433:5432" volumes: - bittuly-pg-urls-data:/var/lib/postgresql/data - - ./docker/postgres_urls/init:/docker-entrypoint-initdb.d:ro + - ./docker/postgres-urls/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U bittu -d bittuly_urls"] interval: 5s From 84ce83c0ceac56a3f18901109a40ecc75fed458c Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:31:18 +0530 Subject: [PATCH 10/32] fix: api routing prefixes --- services/auth-service/src/main.rs | 4 +++- services/auth-service/src/routes.rs | 2 +- services/url-service/src/routes.rs | 14 ++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index 526cdff..1443f2f 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -20,7 +20,9 @@ async fn main() { .expect("Failed to connect to Database"); let auth_state = Arc::new(models::AuthState::new(db)); - let app = routes::auth_routes().with_state(auth_state); + let app = axum::Router::new() + .nest("/api/auth", routes::auth_routes()) + .with_state(auth_state); let listener = tokio::net::TcpListener::bind(format!("{}:{}", settings.server_addr, settings.server_port)) diff --git a/services/auth-service/src/routes.rs b/services/auth-service/src/routes.rs index bd7f275..d0f0906 100644 --- a/services/auth-service/src/routes.rs +++ b/services/auth-service/src/routes.rs @@ -19,7 +19,7 @@ pub fn auth_routes() -> Router { Router::new() .route("/signup", post(request_signup_handler)) // Step 1: send OTP .route("/verify-otp", post(verify_otp_handler)) // Step 2: verify OTP → create user + JWT - .route("/direct-signup", post(create_user)) // Legacy: direct signup (no OTP) + // .route("/direct-signup", post(create_user)) // Legacy: direct signup (no OTP) .route("/login", post(login)) .merge(protected) } diff --git a/services/url-service/src/routes.rs b/services/url-service/src/routes.rs index f9a39f3..8b9445b 100644 --- a/services/url-service/src/routes.rs +++ b/services/url-service/src/routes.rs @@ -12,16 +12,14 @@ use crate::{ use shared::jwt::jwt_auth; pub fn url_routes() -> Router { - // POST / and GET / both require auth - let protected_root = Router::new() + // API endpoints (require auth) + let api_routes = Router::new() .route("/", post(shorten_url).get(get_all_urls)) + .route("/{id}", axum::routing::delete(delete_url_handler)) .layer(middleware::from_fn(jwt_auth)); Router::new() - .merge(protected_root) - // GET /{id} is public; DELETE /{id} requires auth — applied at handler level - .route( - "/{id}", - get(get_original_url).delete(delete_url_handler.layer(middleware::from_fn(jwt_auth))), - ) + .nest("/api/urls", api_routes) + // Public redirect (no auth required) + .route("/{id}", get(get_original_url)) } From 68b5d97e83c28ae53e297305a55f7927b89447f0 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:33:23 +0530 Subject: [PATCH 11/32] fix: unused imports and rm dead code --- services/auth-service/src/handlers.rs | 20 -------------------- services/auth-service/src/routes.rs | 3 +-- services/url-service/src/routes.rs | 4 +--- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index 943f105..9868f16 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -10,26 +10,6 @@ use shared::jwt::{Claims, clear_token_cookies, set_token_cookies}; use uuid::Uuid; use validator::Validate; -pub async fn create_user( - State(state): State, - Json(payload): Json, -) -> impl IntoResponse { - if let Err(errors) = payload.validate() { - return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); - } - match user_service::create_user(&state.db, payload).await { - Ok(auth) => { - let mut response = (StatusCode::CREATED, Json(auth.user)).into_response(); - if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { - tracing::error!("set_token_cookies: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - response - } - Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), - } -} - pub async fn login( State(state): State, Json(payload): Json, diff --git a/services/auth-service/src/routes.rs b/services/auth-service/src/routes.rs index d0f0906..4e98890 100644 --- a/services/auth-service/src/routes.rs +++ b/services/auth-service/src/routes.rs @@ -1,5 +1,5 @@ use crate::handlers::{ - create_user, delete_user, get_user_by_id, login, logout, request_signup_handler, update_user, + delete_user, get_user_by_id, login, logout, request_signup_handler, update_user, verify_otp_handler, }; use crate::models::AuthStateRef; @@ -19,7 +19,6 @@ pub fn auth_routes() -> Router { Router::new() .route("/signup", post(request_signup_handler)) // Step 1: send OTP .route("/verify-otp", post(verify_otp_handler)) // Step 2: verify OTP → create user + JWT - // .route("/direct-signup", post(create_user)) // Legacy: direct signup (no OTP) .route("/login", post(login)) .merge(protected) } diff --git a/services/url-service/src/routes.rs b/services/url-service/src/routes.rs index 8b9445b..0255774 100644 --- a/services/url-service/src/routes.rs +++ b/services/url-service/src/routes.rs @@ -1,7 +1,5 @@ use axum::{ - Router, - handler::Handler, - middleware, + Router, middleware, routing::{get, post}, }; From 1f1a3e754123d25b733a16b9b272bacb3f7ddc22 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:44:47 +0530 Subject: [PATCH 12/32] update: frontend request api routes and baseurl with env var setup --- web/src/api/auth.ts | 16 ++++++++-------- web/src/api/client.ts | 6 ++++-- web/src/api/urls.ts | 10 +++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 6999827..57dc831 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,4 +1,4 @@ -import { apiRequest } from "./client" +import { apiRequest, AUTH_BASE_URL } from "./client" export interface User { id: string @@ -17,7 +17,7 @@ export async function signup(data: { email: string password: string }): Promise { - return apiRequest("/users/signup", { + return apiRequest(AUTH_BASE_URL, "/api/auth/signup", { method: "POST", body: JSON.stringify(data), }) @@ -27,7 +27,7 @@ export async function verifyOtp(data: { pending_token: string otp: string }): Promise { - return apiRequest("/users/verify-otp", { + return apiRequest(AUTH_BASE_URL, "/api/auth/verify-otp", { method: "POST", body: JSON.stringify(data), }) @@ -37,30 +37,30 @@ export async function login(data: { email: string password: string }): Promise { - return apiRequest("/users/login", { + return apiRequest(AUTH_BASE_URL, "/api/auth/login", { method: "POST", body: JSON.stringify(data), }) } export async function logout(): Promise { - return apiRequest("/users/logout", { method: "POST" }) + return apiRequest(AUTH_BASE_URL, "/api/auth/logout", { method: "POST" }) } export async function getUser(id: string): Promise { - return apiRequest(`/users/${id}`) + return apiRequest(AUTH_BASE_URL, `/api/auth/${id}`) } export async function updateUser( id: string, data: { username?: string; email?: string; password?: string } ): Promise { - return apiRequest(`/users/${id}`, { + return apiRequest(AUTH_BASE_URL, `/api/auth/${id}`, { method: "PUT", body: JSON.stringify(data), }) } export async function deleteUser(id: string): Promise { - return apiRequest(`/users/${id}`, { method: "DELETE" }) + return apiRequest(AUTH_BASE_URL, `/api/auth/${id}`, { method: "DELETE" }) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 94ae45f..8647f5f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,5 @@ -const BASE_URL = "http://localhost:3000" +export const AUTH_BASE_URL = import.meta.env.VITE_AUTH_API_URL || "http://localhost:3001" +export const URLS_BASE_URL = import.meta.env.VITE_URLS_API_URL || "http://localhost:3002" export interface ApiError { status: number @@ -6,10 +7,11 @@ export interface ApiError { } export async function apiRequest( + baseUrl: string, path: string, options: RequestInit = {} ): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await fetch(`${baseUrl}${path}`, { ...options, credentials: "include", headers: { diff --git a/web/src/api/urls.ts b/web/src/api/urls.ts index 77cba2c..7e13a89 100644 --- a/web/src/api/urls.ts +++ b/web/src/api/urls.ts @@ -1,4 +1,4 @@ -import { apiRequest } from "./client" +import { apiRequest, URLS_BASE_URL } from "./client" export interface ShortenedUrl { id: number @@ -14,7 +14,7 @@ export interface UrlsPage { } export async function createUrl(original_url: string): Promise { - return apiRequest("/", { + return apiRequest(URLS_BASE_URL, "/api/urls", { method: "POST", body: JSON.stringify({ original_url }), }) @@ -29,16 +29,16 @@ export async function getUrlsPage( if (cursor) params.set("cursor", cursor) params.set("limit", String(limit)) if (search && search.trim()) params.set("search", search.trim()) - return apiRequest(`/?${params.toString()}`) + return apiRequest(URLS_BASE_URL, `/api/urls?${params.toString()}`) } /** Convenience: fetch all URLs for non-paginated views (Insights). */ export async function getUrls(): Promise { const params = new URLSearchParams({ limit: "100" }) - const page = await apiRequest(`/?${params.toString()}`) + const page = await apiRequest(URLS_BASE_URL, `/api/urls?${params.toString()}`) return page.urls } export async function deleteUrl(id: number): Promise { - return apiRequest(`/${id}`, { method: "DELETE" }) + return apiRequest(URLS_BASE_URL, `/api/urls/${id}`, { method: "DELETE" }) } From 73129fc4470ec9487ded81665dbe1872c71dab4d Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:50:37 +0530 Subject: [PATCH 13/32] fix: added CORS layer via tower-http to allow fronend reqs and allow cookies to work --- Cargo.lock | 1 + services/auth-service/Cargo.toml | 1 + services/auth-service/src/main.rs | 25 +++++++++++++++++++++++++ services/url-service/src/main.rs | 28 +++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index baffda5..29cb31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,7 @@ dependencies = [ "shared", "sqlx", "tokio", + "tower-http", "tracing", "tracing-subscriber", "uuid", diff --git a/services/auth-service/Cargo.toml b/services/auth-service/Cargo.toml index d9daa6d..0e394fd 100644 --- a/services/auth-service/Cargo.toml +++ b/services/auth-service/Cargo.toml @@ -18,3 +18,4 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } bcrypt = "0.15" +tower-http = { version = "0.6.11", features = ["cors", "trace"] } diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index 1443f2f..ad69ebf 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -11,17 +11,42 @@ mod services; use shared::config; use shared::postgres; use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "tower_http=debug,auth_service=debug,shared=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + let settings = config::AuthConfig::from_env().expect("Failed to load setting from environment"); let db = postgres::init_pg_pool(&settings.database_url) .await .expect("Failed to connect to Database"); let auth_state = Arc::new(models::AuthState::new(db)); + + let cors = CorsLayer::new() + .allow_origin( + settings + .cors_origin + .parse::() + .expect("Invalid CORS origin"), + ) + .allow_methods(Any) + .allow_headers(Any) + .allow_credentials(true); + let app = axum::Router::new() .nest("/api/auth", routes::auth_routes()) + .layer(cors) + .layer(TraceLayer::new_for_http()) .with_state(auth_state); let listener = diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index 2e73ebf..7c7258a 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -10,11 +10,22 @@ use shared::config; use shared::postgres; use shared::redis; use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tokio::sync::mpsc; #[tokio::main] async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "tower_http=debug,url_service=debug,shared=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + let settings = config::UrlConfig::from_env().expect("Failed to load setting from environment"); let db = postgres::init_pg_pool(&settings.database_url) .await @@ -30,7 +41,22 @@ async fn main() { let (tx, rx) = mpsc::unbounded_channel::(); let consumer_handler = consumer::spawn_consumer(rx, consumer_db); let url_state = Arc::new(models::UrlState::new(tx.clone(), redis, db)); - let app = routes::url_routes().with_state(url_state); + + let cors = CorsLayer::new() + .allow_origin( + settings + .cors_origin + .parse::() + .expect("Invalid CORS origin"), + ) + .allow_methods(Any) + .allow_headers(Any) + .allow_credentials(true); + + let app = routes::url_routes() + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(url_state); let listener = tokio::net::TcpListener::bind(format!("{}:{}", settings.server_addr, settings.server_port)) From 68872ebb6aa9e49effb690d3ff120aad68258f35 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 22:54:59 +0530 Subject: [PATCH 14/32] add .env.example with API urls for auth and urls services --- web/.env.example | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 web/.env.example diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..4a9ca53 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +VITE_AUTH_API_URL=http://localhost:3001 +VITE_URLS_API_URL=http://localhost:3002 From a4fd22bf3c4e97ae675fbdb827cae4d24500d196 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 23:28:51 +0530 Subject: [PATCH 15/32] fix CORS config, rm hardcoded baseurls and show unavailable page on incorrect short code req --- .cargo/config.toml | 3 +++ services/auth-service/src/main.rs | 15 ++++++++++----- services/url-service/src/handlers.rs | 2 +- services/url-service/src/main.rs | 15 ++++++++++----- services/url-service/src/models.rs | 4 +++- web/src/components/UrlItem.tsx | 7 +++---- web/src/pages/Insights.tsx | 3 ++- web/src/pages/Unavailable.tsx | 22 ++++++++++++++++++++++ web/src/router/AppRouter.tsx | 3 +++ 9 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 web/src/pages/Unavailable.tsx diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3ff4a04 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +dev-auth = ["watch", "-c", "-w", "services/auth-service", "-w", "libs/shared", "-x", "run -p auth-service"] +dev-urls = ["watch", "-c", "-w", "services/url-service", "-w", "libs/shared", "-x", "run -p url-service"] diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index ad69ebf..ca36026 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -31,7 +31,7 @@ async fn main() { .expect("Failed to connect to Database"); let auth_state = Arc::new(models::AuthState::new(db)); - + let cors = CorsLayer::new() .allow_origin( settings @@ -39,8 +39,13 @@ async fn main() { .parse::() .expect("Invalid CORS origin"), ) - .allow_methods(Any) - .allow_headers(Any) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + ]) + .allow_headers([axum::http::header::CONTENT_TYPE]) .allow_credentials(true); let app = axum::Router::new() @@ -55,8 +60,8 @@ async fn main() { .expect("failed to bind listener"); println!( - "Auth service listening on {} [mode={} cors={}]", - settings.server_addr, settings.mode, settings.cors_origin + "Auth service listening on {}:{} [mode={} cors={}]", + settings.server_addr, settings.server_port, settings.mode, settings.cors_origin ); if let Err(err) = axum::serve(listener, app).await { diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 5b2b0c1..ad14638 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -135,7 +135,7 @@ pub async fn get_original_url( } Redirect::temporary(&original_url).into_response() } - Ok(None) => StatusCode::NOT_FOUND.into_response(), + Ok(None) => Redirect::temporary(&format!("{}/unavailable", state.cors_origin)).into_response(), Err(err) => { tracing::error!("{:?}", err); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index 7c7258a..e153927 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -40,7 +40,7 @@ async fn main() { let consumer_db = db.clone(); let (tx, rx) = mpsc::unbounded_channel::(); let consumer_handler = consumer::spawn_consumer(rx, consumer_db); - let url_state = Arc::new(models::UrlState::new(tx.clone(), redis, db)); + let url_state = Arc::new(models::UrlState::new(tx.clone(), redis, db, settings.cors_origin.clone())); let cors = CorsLayer::new() .allow_origin( @@ -49,8 +49,13 @@ async fn main() { .parse::() .expect("Invalid CORS origin"), ) - .allow_methods(Any) - .allow_headers(Any) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + ]) + .allow_headers([axum::http::header::CONTENT_TYPE]) .allow_credentials(true); let app = routes::url_routes() @@ -64,8 +69,8 @@ async fn main() { .expect("failed to bind listener"); println!( - "listening on {} [mode={} cors={}]", - settings.server_addr, settings.mode, settings.cors_origin + "Url service listening on {}:{} [mode={} cors={}]", + settings.server_addr, settings.server_port, settings.mode, settings.cors_origin ); if let Err(err) = axum::serve(listener, app).await { diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index 904f35f..beaa8a2 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -16,15 +16,17 @@ pub struct UrlState { pub tx: ClickSender, pub redis: RedisConn, pub started_at: Instant, + pub cors_origin: String, } impl UrlState { - pub fn new(tx: ClickSender, redis: RedisConn, db: PgPool) -> Self { + pub fn new(tx: ClickSender, redis: RedisConn, db: PgPool, cors_origin: String) -> Self { Self { db, tx, redis, started_at: Instant::now(), + cors_origin, } } } diff --git a/web/src/components/UrlItem.tsx b/web/src/components/UrlItem.tsx index 5b0d70f..bb14591 100644 --- a/web/src/components/UrlItem.tsx +++ b/web/src/components/UrlItem.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Copy, Check, Trash2, ExternalLink, X, BarChart2 } from "lucide-react" +import { Copy, Check, Trash2, ExternalLink, BarChart2, X } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -9,8 +9,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import type { ShortenedUrl } from "@/api/urls" - -const BASE_URL = "http://localhost:3000" +import { URLS_BASE_URL } from "@/api/client" interface UrlItemProps { url: ShortenedUrl @@ -22,7 +21,7 @@ export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { const [copied, setCopied] = React.useState(false) const [confirming, setConfirming] = React.useState(false) const confirmTimerRef = React.useRef | null>(null) - const shortUrl = `${BASE_URL}/${url.short_code}` + const shortUrl = `${URLS_BASE_URL}/${url.short_code}` const handleCopy = async () => { try { diff --git a/web/src/pages/Insights.tsx b/web/src/pages/Insights.tsx index 5f978aa..58b796e 100644 --- a/web/src/pages/Insights.tsx +++ b/web/src/pages/Insights.tsx @@ -1,5 +1,6 @@ import * as React from "react" import { getUrls, type ShortenedUrl } from "@/api/urls" +import { URLS_BASE_URL } from "@/api/client" import { AppLayout } from "@/components/AppLayout" import { PieChart, @@ -319,7 +320,7 @@ export function Insights() { style={{ background: COLORS[i % COLORS.length] }} /> +
+
+
+ +
+ +

Link Unavailable

+ +

+ The short link you clicked does not exist or has been deleted by its owner. +

+ +
+
+ + ) +} diff --git a/web/src/router/AppRouter.tsx b/web/src/router/AppRouter.tsx index 9999966..dd2b8db 100644 --- a/web/src/router/AppRouter.tsx +++ b/web/src/router/AppRouter.tsx @@ -8,6 +8,7 @@ import { Profile } from "@/pages/Profile" import { Settings } from "@/pages/Settings" import { Insights } from "@/pages/Insights" import { Health } from "@/pages/Health" +import { Unavailable } from "@/pages/Unavailable" export function AppRouter() { return ( @@ -55,7 +56,9 @@ export function AppRouter() { } /> + } /> } /> + } /> ) } From 8d52fa6cafa837b593d265086a52be60583ad6d5 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Sun, 14 Jun 2026 23:39:01 +0530 Subject: [PATCH 16/32] update: README.md --- README.md | 84 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 989b9d6..0a5ae9a 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,83 @@ -# bittuly -Distibuted URL Shortener +# Bittuly - Distributed URL Shortener + +Bittuly is a production-grade, distributed URL shortener built with Rust (Axum) and React (Vite). It uses a microservices architecture, separating authentication and URL management into distinct services with their own isolated databases. + +## 🏗️ System Architecture + +- **`auth-service` (Port 3001)**: Handles user signups, stateless OTP verification (via email or console), JWT generation, and user management. +- **`url-service` (Port 3002)**: Handles URL shortening, redirects, and click analytics tracking. Uses Redis for caching short URLs and a background async worker for processing click metrics. +- **`libs/shared`**: Shared Rust crate containing JWT logic, configurations, database connections, and middleware. +- **`web/` (Port 5173)**: Modern React frontend built with Vite, Tailwind CSS, and a beautiful Notion-inspired design system. + +--- + +## 🚀 Getting Started + +### 1. Start the Infrastructure (Databases & Cache) +The system requires two separate PostgreSQL databases and a Redis cache. These are fully containerized. -# Docker commands ```bash docker compose up -d ``` +This spins up: +- `postgres-auth` (Port 5432) — Database: `bittuly_auth` +- `postgres-urls` (Port 5433) — Database: `bittuly_urls` +- `redis` (Port 6379) + +*Note: The Postgres schemas are automatically created via the init scripts in `docker/postgres-auth/init` and `docker/postgres-urls/init` the first time the containers start.* + +### 2. Configure Environment Variables +**Backend (`/.env`):** +The root `.env` file configures the backend. +By default, `MODE=development` will bypass real SMTP emails and print your OTP code to the terminal. +To test real emails, set `MODE=production` and ensure `SMTP_USER` and `SMTP_PASS` are configured correctly. + +**Frontend (`web/.env`):** +Ensure the frontend `.env` points to the correct backend ports: +```env +VITE_AUTH_API_URL=http://localhost:3001 +VITE_URLS_API_URL=http://localhost:3002 +``` -This starts PostgreSQL on `localhost:5432` with: +### 3. Start the Microservices +We have set up convenient Cargo aliases using `cargo-watch` so that both services auto-reload on code changes. You will need two separate terminal windows for the backend. -- username: `bittu` -- password: `bittu` -- database: `bittuly` -- tables: `users`, `urls` +*(Ensure you have cargo-watch installed: `cargo install cargo-watch`)* -Postgres init scripts only run when the data volume is first created. If you later change schema and need a clean DB, use: +**Terminal 1 (Auth Service):** ```bash - docker compose down -v - docker compose up -d +cargo dev-auth ``` -## Usage -To see the test workflow +**Terminal 2 (URL Service):** +```bash +cargo dev-urls +``` + +### 4. Start the Frontend +Open a third terminal window, navigate to the `web/` directory, install dependencies, and start the Vite development server: + +```bash +cd web +npm install +npm run dev +``` + +Your frontend is now available at `http://localhost:5173`! + +--- -Make the bash script runnable +## 🛠️ Useful Commands + +### Wiping the Databases +If you modify the SQL schema files and need to start fresh, you must destroy the Docker volumes and recreate them: ```bash -chmod +x ./scripts/api-test.sh +docker compose down -v +docker compose up -d ``` -then execute the `api-test.sh` script and it will show the request response cycle for all the requests made. +### Building for Production ```bash -./scripts/api-test.sh +cargo build --release ``` +This builds optimized binaries for both services in the `target/release` folder. From 8051e6c4a1576a69a8031448f5de62843e5066c0 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 01:08:17 +0530 Subject: [PATCH 17/32] add: continuous integration --- .github/workflows/ci.yml | 64 ++++++++++++++++++++++++++ README.md | 20 ++++++++ libs/shared/src/jwt.rs | 54 ++++++++++++++++------ scripts/check.sh | 35 ++++++++++++++ services/auth-service/src/health.rs | 1 + services/auth-service/src/main.rs | 3 +- services/auth-service/src/models.rs | 1 + services/auth-service/src/otp_store.rs | 2 +- services/auth-service/src/services.rs | 1 + services/url-service/src/handlers.rs | 4 +- services/url-service/src/health.rs | 1 + services/url-service/src/main.rs | 9 +++- services/url-service/src/models.rs | 1 + 13 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57313f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: Bittuly CI + +on: + push: + branches: [ "main", "dist_sys" ] + pull_request: + branches: [ "main", "dist_sys" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + backend-checks: + name: Backend (Rust) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy Lints + run: cargo clippy --workspace -- -D warnings + + - name: Run Tests + run: cargo test --workspace + + frontend-checks: + name: Frontend (React) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./web + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '26' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Verify Build + run: npm run build diff --git a/README.md b/README.md index 0a5ae9a..6d86746 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,23 @@ docker compose up -d cargo build --release ``` This builds optimized binaries for both services in the `target/release` folder. + +--- + +## 🛡️ Continuous Integration (CI) & Git Hooks + +This project uses GitHub Actions to automatically format, lint, and test both the Rust backend and React frontend on every push. + +If you want to run these exact same checks locally before pushing, you can run the provided check script: +```bash +./scripts/check.sh +``` + +**Recommended: Set up a Git Pre-Push Hook** +To ensure you never push broken code to GitHub, you can force Git to automatically run this script every time you type `git push`. If the checks fail, the push is aborted. + +To install the hook, simply run: +```bash +cp scripts/check.sh .git/hooks/pre-push +chmod +x .git/hooks/pre-push +``` diff --git a/libs/shared/src/jwt.rs b/libs/shared/src/jwt.rs index 9df7bd9..79ad4e8 100644 --- a/libs/shared/src/jwt.rs +++ b/libs/shared/src/jwt.rs @@ -14,9 +14,9 @@ use uuid::Uuid; const ACCESS_TOKEN_TYPE: &str = "access"; const REFRESH_TOKEN_TYPE: &str = "refresh"; const PENDING_TOKEN_TYPE: &str = "pending"; -const ACCESS_TOKEN_TTL_SECONDS: u64 = 60 * 15; // 15 minutes +const ACCESS_TOKEN_TTL_SECONDS: u64 = 60 * 15; // 15 minutes const REFRESH_TOKEN_TTL_SECONDS: u64 = 60 * 60 * 24 * 30; // 30 days -const PENDING_TOKEN_TTL_SECONDS: u64 = 60 * 10; // 10 minutes +const PENDING_TOKEN_TTL_SECONDS: u64 = 60 * 10; // 10 minutes const COOKIE_ACCESS: &str = "access_token"; const COOKIE_REFRESH: &str = "refresh_token"; @@ -34,15 +34,27 @@ fn create_token( ) -> Result> { let secret = std::env::var("JWT_SECRET")?; let exp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + ttl_seconds; - let claims = Claims { sub: user_id, exp: exp as usize, token_type: token_type.to_string() }; - Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) + let claims = Claims { + sub: user_id, + exp: exp as usize, + token_type: token_type.to_string(), + }; + Ok(encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?) } -pub fn create_access_token(user_id: Uuid) -> Result> { +pub fn create_access_token( + user_id: Uuid, +) -> Result> { create_token(user_id, ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TTL_SECONDS) } -pub fn create_refresh_token(user_id: Uuid) -> Result> { +pub fn create_refresh_token( + user_id: Uuid, +) -> Result> { create_token(user_id, REFRESH_TOKEN_TYPE, REFRESH_TOKEN_TTL_SECONDS) } @@ -79,7 +91,11 @@ pub fn create_pending_token( otp_hash: otp_hash.to_string(), exp: exp as usize, }; - Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?) + Ok(encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?) } /// Decode and validate a pending signup JWT. @@ -120,11 +136,19 @@ pub fn set_token_cookies( #[cfg(not(debug_assertions))] let flags = "HttpOnly; Secure; SameSite=Strict"; - let access = format!("{COOKIE_ACCESS}={access_token}; {flags}; Max-Age={ACCESS_TOKEN_TTL_SECONDS}; Path=/"); - let refresh = format!("{COOKIE_REFRESH}={refresh_token}; {flags}; Max-Age={REFRESH_TOKEN_TTL_SECONDS}; Path=/"); - - response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&access)?); - response.headers_mut().append(header::SET_COOKIE, HeaderValue::from_str(&refresh)?); + let access = format!( + "{COOKIE_ACCESS}={access_token}; {flags}; Max-Age={ACCESS_TOKEN_TTL_SECONDS}; Path=/" + ); + let refresh = format!( + "{COOKIE_REFRESH}={refresh_token}; {flags}; Max-Age={REFRESH_TOKEN_TTL_SECONDS}; Path=/" + ); + + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_str(&access)?); + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_str(&refresh)?); Ok(()) } @@ -175,8 +199,7 @@ async fn refresh_access_token( cookie_str: &str, secret: &str, ) -> Result { - let refresh_token = - parse_cookie(cookie_str, COOKIE_REFRESH).ok_or(StatusCode::UNAUTHORIZED)?; + let refresh_token = parse_cookie(cookie_str, COOKIE_REFRESH).ok_or(StatusCode::UNAUTHORIZED)?; let data = decode::( &refresh_token, @@ -191,7 +214,8 @@ async fn refresh_access_token( let user_id = data.claims.sub; let new_access = create_access_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let new_refresh = create_refresh_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let new_refresh = + create_refresh_token(user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; req.extensions_mut().insert(Claims { sub: user_id, diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..ff3250e --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# A local script to run the exact same checks as the GitHub Actions CI pipeline + +set -e # Exit immediately if a command exits with a non-zero status. + +echo "=========================================" +echo "🚀 Running Local CI Checks..." +echo "=========================================" + +echo "" +echo "🦀 1. Formatting Rust Code (cargo fmt)..." +cargo fmt --all + +echo "" +echo "🦀 2. Linting Rust Code (cargo clippy)..." +cargo clippy --workspace -- -D warnings + +echo "" +echo "🦀 3. Testing Rust Backend (cargo test)..." +cargo test --workspace + +echo "" +echo "⚛️ 4. Typechecking React Frontend (npm run typecheck)..." +cd web +npm run typecheck + +echo "" +echo "⚛️ 5. Verifying React Build (npm run build)..." +npm run build +cd .. + +echo "" +echo "=========================================" +echo "✅ All checks passed successfully! You are ready to commit and push." +echo "=========================================" diff --git a/services/auth-service/src/health.rs b/services/auth-service/src/health.rs index f7c9ec4..7572888 100644 --- a/services/auth-service/src/health.rs +++ b/services/auth-service/src/health.rs @@ -19,6 +19,7 @@ pub struct HealthResponse { /// /// Public endpoint — no authentication required. /// Returns 200 when all dependencies are reachable, 503 otherwise. +#[allow(dead_code)] pub async fn health(State(state): State) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index ca36026..ee7f755 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -11,11 +11,12 @@ mod services; use shared::config; use shared::postgres; use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] +#[allow(dead_code)] async fn main() { tracing_subscriber::registry() .with( diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index 4d7b557..1bdd77b 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -11,6 +11,7 @@ use validator::Validate; pub struct AuthState { pub db: PgPool, + #[allow(dead_code)] pub started_at: Instant, } diff --git a/services/auth-service/src/otp_store.rs b/services/auth-service/src/otp_store.rs index 884502f..0fc5899 100644 --- a/services/auth-service/src/otp_store.rs +++ b/services/auth-service/src/otp_store.rs @@ -44,6 +44,6 @@ pub fn get_all() -> Vec { .unwrap_or_default(); let mut sorted = entries; - sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + sorted.sort_by_key(|b| std::cmp::Reverse(b.created_at)); sorted } diff --git a/services/auth-service/src/services.rs b/services/auth-service/src/services.rs index 90428d6..5e8cc54 100644 --- a/services/auth-service/src/services.rs +++ b/services/auth-service/src/services.rs @@ -10,6 +10,7 @@ use uuid::Uuid; type ServiceResult = Result>; +#[allow(dead_code)] pub async fn create_user( db: &DbPool, mut payload: CreateUserPayload, diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index ad14638..4f0997e 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -135,7 +135,9 @@ pub async fn get_original_url( } Redirect::temporary(&original_url).into_response() } - Ok(None) => Redirect::temporary(&format!("{}/unavailable", state.cors_origin)).into_response(), + Ok(None) => { + Redirect::temporary(&format!("{}/unavailable", state.cors_origin)).into_response() + } Err(err) => { tracing::error!("{:?}", err); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/services/url-service/src/health.rs b/services/url-service/src/health.rs index 492a4f8..dfee903 100644 --- a/services/url-service/src/health.rs +++ b/services/url-service/src/health.rs @@ -20,6 +20,7 @@ pub struct HealthResponse { /// /// Public endpoint — no authentication required. /// Returns 200 when all dependencies are reachable, 503 otherwise. +#[allow(dead_code)] pub async fn health(State(state): State) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index e153927..9dda451 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -10,7 +10,7 @@ use shared::config; use shared::postgres; use shared::redis; use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -40,7 +40,12 @@ async fn main() { let consumer_db = db.clone(); let (tx, rx) = mpsc::unbounded_channel::(); let consumer_handler = consumer::spawn_consumer(rx, consumer_db); - let url_state = Arc::new(models::UrlState::new(tx.clone(), redis, db, settings.cors_origin.clone())); + let url_state = Arc::new(models::UrlState::new( + tx.clone(), + redis, + db, + settings.cors_origin.clone(), + )); let cors = CorsLayer::new() .allow_origin( diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index beaa8a2..edc63c4 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -15,6 +15,7 @@ pub struct UrlState { pub db: PgPool, pub tx: ClickSender, pub redis: RedisConn, + #[allow(dead_code)] pub started_at: Instant, pub cors_origin: String, } From 4dbf6ea2e02182f418d4a34ffd3be5356411ab8b Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 02:21:42 +0530 Subject: [PATCH 18/32] add nginx proxy server and connect the two microservices and the VITE SPA --- docker-compose.yml | 12 ++++++ docker/nginx/nginx.conf | 60 ++++++++++++++++++++++++++++++ services/auth-service/src/main.rs | 5 ++- services/url-service/src/main.rs | 5 ++- services/url-service/src/routes.rs | 11 ++++-- web/src/api/health.ts | 6 +-- web/src/api/urls.ts | 6 +-- web/src/pages/Health.tsx | 2 +- 8 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 docker/nginx/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index feeb4ec..eba154e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + api-gateway: + image: nginx:1.31.1-alpine + container_name: bittuly-api-gateway + network_mode: "host" # Allows NGINX to proxy to 127.0.0.1 on the host machine + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + postgres-auth: + condition: service_healthy + postgres-urls: + condition: service_healthy + postgres-auth: image: postgres:17 container_name: bittuly-postgres-auth diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..d419358 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,60 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + + server { + listen 8000; + server_name localhost; + + # Forward actual IP and Protocol headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ----------------------------------------- + # 1. API Routes + # ----------------------------------------- + location /api/auth { + proxy_pass http://127.0.0.1:3001; + } + + location /api/urls { + proxy_pass http://127.0.0.1:3002; + } + + # ----------------------------------------- + # 2. Frontend SPA (Vite) + # ----------------------------------------- + # Exact root goes to Frontend + location = / { + proxy_pass http://127.0.0.1:5173; + } + + # Known frontend routes and assets + location ~ ^/(dashboard|login|signup|verify-otp|profile|settings|insights|health|unavailable|assets|src|node_modules|@fs|@vite|@react-refresh|__vite_ping|favicon.ico|manifest.json) { + proxy_pass http://127.0.0.1:5173; + + # WebSocket support for Vite HMR + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # ----------------------------------------- + # 3. Short URLs (Catch-all) + # ----------------------------------------- + # Any path that isn't an API or known Frontend route is assumed + # to be a short code and goes to the URL service. + location / { + proxy_pass http://127.0.0.1:3002; + } + } +} diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index ee7f755..ddaa2a4 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -46,7 +46,10 @@ async fn main() { axum::http::Method::PUT, axum::http::Method::DELETE, ]) - .allow_headers([axum::http::header::CONTENT_TYPE]) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]) .allow_credentials(true); let app = axum::Router::new() diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index 9dda451..d2f8069 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -60,7 +60,10 @@ async fn main() { axum::http::Method::PUT, axum::http::Method::DELETE, ]) - .allow_headers([axum::http::header::CONTENT_TYPE]) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]) .allow_credentials(true); let app = routes::url_routes() diff --git a/services/url-service/src/routes.rs b/services/url-service/src/routes.rs index 0255774..073165e 100644 --- a/services/url-service/src/routes.rs +++ b/services/url-service/src/routes.rs @@ -5,19 +5,22 @@ use axum::{ use crate::{ handlers::{delete_url_handler, get_all_urls, get_original_url, shorten_url}, + health::health, models::UrlStateRef, }; use shared::jwt::jwt_auth; pub fn url_routes() -> Router { // API endpoints (require auth) - let api_routes = Router::new() - .route("/", post(shorten_url).get(get_all_urls)) - .route("/{id}", axum::routing::delete(delete_url_handler)) + let protected = Router::new() + .route("/api/urls", post(shorten_url).get(get_all_urls)) + .route("/api/urls/", post(shorten_url).get(get_all_urls)) + .route("/api/urls/{id}", axum::routing::delete(delete_url_handler)) .layer(middleware::from_fn(jwt_auth)); Router::new() - .nest("/api/urls", api_routes) + .merge(protected) + .route("/api/urls/health", get(health)) // Public redirect (no auth required) .route("/{id}", get(get_original_url)) } diff --git a/web/src/api/health.ts b/web/src/api/health.ts index 9cec797..f413e6a 100644 --- a/web/src/api/health.ts +++ b/web/src/api/health.ts @@ -1,4 +1,4 @@ -const BASE_URL = "http://localhost:3000" +const URLS_BASE_URL = import.meta.env.VITE_URLS_API_URL || "http://localhost:8000" export interface HealthData { status: "healthy" | "degraded" @@ -9,12 +9,12 @@ export interface HealthData { } /** - * Fetches /health — never throws. + * Fetches /api/urls/health — never throws. * Returns the response body whether the server returned 200 or 503. */ export async function getHealth(): Promise<{ data: HealthData; ok: boolean }> { try { - const res = await fetch(`${BASE_URL}/health`) + const res = await fetch(`${URLS_BASE_URL}/api/urls/health`) const data: HealthData = await res.json() return { data, ok: res.ok } } catch { diff --git a/web/src/api/urls.ts b/web/src/api/urls.ts index 7e13a89..287857b 100644 --- a/web/src/api/urls.ts +++ b/web/src/api/urls.ts @@ -14,7 +14,7 @@ export interface UrlsPage { } export async function createUrl(original_url: string): Promise { - return apiRequest(URLS_BASE_URL, "/api/urls", { + return apiRequest(URLS_BASE_URL, "/api/urls/", { method: "POST", body: JSON.stringify({ original_url }), }) @@ -29,13 +29,13 @@ export async function getUrlsPage( if (cursor) params.set("cursor", cursor) params.set("limit", String(limit)) if (search && search.trim()) params.set("search", search.trim()) - return apiRequest(URLS_BASE_URL, `/api/urls?${params.toString()}`) + return apiRequest(URLS_BASE_URL, `/api/urls/?${params.toString()}`) } /** Convenience: fetch all URLs for non-paginated views (Insights). */ export async function getUrls(): Promise { const params = new URLSearchParams({ limit: "100" }) - const page = await apiRequest(URLS_BASE_URL, `/api/urls?${params.toString()}`) + const page = await apiRequest(URLS_BASE_URL, `/api/urls/?${params.toString()}`) return page.urls } diff --git a/web/src/pages/Health.tsx b/web/src/pages/Health.tsx index 90b203b..7fd1898 100644 --- a/web/src/pages/Health.tsx +++ b/web/src/pages/Health.tsx @@ -249,7 +249,7 @@ export function Health() { From 80669d0b6c5004fe7ccbe8f37bff447ddf84821a Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 15:41:09 +0530 Subject: [PATCH 19/32] rm mpsc::channel based consumer && introduced rabbitmq based consumer for events: user_deleted, click --- .cargo/config.toml | 1 + Cargo.lock | 1043 +++++++++++++++++++++++- Cargo.toml | 2 +- docker-compose.yml | 15 + libs/shared/Cargo.toml | 2 + libs/shared/src/lib.rs | 4 + libs/shared/src/rabbitmq.rs | 34 + services/auth-service/src/handlers.rs | 20 +- services/auth-service/src/main.rs | 6 +- services/auth-service/src/models.rs | 4 +- services/consumer-service/Cargo.toml | 19 + services/consumer-service/src/main.rs | 249 ++++++ services/url-service/src/consumer.rs | 62 -- services/url-service/src/handlers.rs | 31 +- services/url-service/src/main.rs | 18 +- services/url-service/src/models.rs | 15 +- services/url-service/src/repository.rs | 27 - 17 files changed, 1409 insertions(+), 143 deletions(-) create mode 100644 libs/shared/src/rabbitmq.rs create mode 100644 services/consumer-service/Cargo.toml create mode 100644 services/consumer-service/src/main.rs delete mode 100644 services/url-service/src/consumer.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 3ff4a04..4942823 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,4 @@ [alias] dev-auth = ["watch", "-c", "-w", "services/auth-service", "-w", "libs/shared", "-x", "run -p auth-service"] dev-urls = ["watch", "-c", "-w", "services/url-service", "-w", "libs/shared", "-x", "run -p url-service"] +dev-consumer = ["watch", "-c", "-w", "services/consumer-service", "-w", "libs/shared", "-x", "run -p consumer-service"] diff --git a/Cargo.lock b/Cargo.lock index 29cb31a..9e2bae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +28,54 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "amq-protocol" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587d313f3a8b4a40f866cc84b6059fe83133bf172165ac3b583129dd211d8e1c" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom 7.1.3", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc707ab9aa964a85d9fc25908a3fdc486d2e619406883b3105b48bf304a8d606" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" +dependencies = [ + "cookie-factory", + "nom 7.1.3", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f8273826a676282208e5af38461a07fe939def57396af6ad5997fcf56577d" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -47,17 +106,172 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.4.1", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" +dependencies = [ + "async-global-executor", + "async-trait", + "executor-trait", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io 1.13.0", + "async-trait", + "futures-core", + "reactor-trait", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -169,7 +383,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand", + "fastrand 2.4.1", ] [[package]] @@ -209,6 +423,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.12.1" @@ -236,6 +456,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -264,6 +506,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.64" @@ -321,6 +572,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "combine" version = "4.6.7" @@ -350,6 +613,41 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "consumer-service" +version = "0.1.0" +dependencies = [ + "deadpool-lapin", + "dotenvy", + "futures-lite 2.6.1", + "lapin", + "redis", + "serde", + "serde_json", + "shared", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -506,6 +804,44 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-lapin" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c7b14064f854a3969735e7c948c677a57ef17ca7f0bc029da8fe2e5e0fc1eb" +dependencies = [ + "deadpool", + "lapin", + "tokio-executor-trait", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + [[package]] name = "der" version = "0.7.10" @@ -513,10 +849,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -526,6 +889,15 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -560,6 +932,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dotenvy" version = "0.15.7" @@ -656,6 +1034,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -666,6 +1054,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -683,10 +1077,28 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] +[[package]] +name = "executor-trait" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c39dff9342e4e0e16ce96be751eb21a94e94a87bb2f6e63ad1961c2ce109bf" +dependencies = [ + "async-trait", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -715,6 +1127,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "flume" version = "0.12.0" @@ -797,6 +1226,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.4.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -924,6 +1381,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1212,9 +1681,30 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1257,6 +1747,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lapin" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2aa4725b9607915fa1a73e940710a3be6af508ce700e56897cbe8847fbb07" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume 0.11.1", + "futures-core", + "futures-io", + "parking_lot", + "pinky-swear", + "reactor-trait", + "serde", + "tracing", + "waker-fn", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1282,17 +1794,17 @@ dependencies = [ "base64", "email-encoding", "email_address", - "fastrand", + "fastrand 2.4.1", "futures-io", "futures-util", "httpdate", "idna", "mime", - "nom", + "nom 8.0.0", "percent-encoding", "quoted_printable", "rustls", - "socket2", + "socket2 0.6.4", "tokio", "tokio-rustls", "url", @@ -1321,6 +1833,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1379,6 +1903,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.1" @@ -1390,6 +1920,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -1470,12 +2010,59 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "p12-keystore" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" +dependencies = [ + "cbc", + "cms", + "der", + "des", + "hex", + "hmac 0.12.1", + "pkcs12", + "pkcs5", + "rand 0.9.4", + "rc2", + "sha1 0.10.6", + "sha2 0.10.9", + "thiserror", + "x509-parser", +] + [[package]] name = "p256" version = "0.13.2" @@ -1529,6 +2116,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pem" version = "3.0.6" @@ -1560,6 +2157,29 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pinky-swear" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ea6e230dd3a64d61bcb8b79e597d3ab6b4c94ec7a234ce687dd718b4f2e657" +dependencies = [ + "doc-comment", + "flume 0.11.1", + "parking_lot", + "tracing", +] + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.4.1", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -1571,6 +2191,36 @@ dependencies = [ "spki", ] +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid", + "der", + "digest 0.10.7", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1587,6 +2237,36 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1764,6 +2444,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + +[[package]] +name = "reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", +] + [[package]] name = "redis" version = "1.2.3" @@ -1772,7 +2472,7 @@ checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" dependencies = [ "arc-swap", "arcstr", - "async-lock", + "async-lock 3.4.2", "backon", "bytes", "cfg-if", @@ -1785,7 +2485,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2", + "socket2 0.6.4", "tokio", "tokio-util", "url", @@ -1798,7 +2498,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.12.1", ] [[package]] @@ -1883,6 +2583,42 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1898,6 +2634,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-connector" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" +dependencies = [ + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1930,12 +2701,41 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sec1" version = "0.7.3" @@ -1950,6 +2750,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2022,6 +2845,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2075,8 +2909,10 @@ name = "shared" version = "0.1.0" dependencies = [ "axum", + "deadpool-lapin", "dotenvy", "jsonwebtoken", + "lapin", "redis", "serde", "sqlx", @@ -2129,6 +2965,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.6.4" @@ -2184,7 +3030,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -2252,7 +3098,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags", + "bitflags 2.12.1", "byteorder", "bytes", "chrono", @@ -2266,7 +3112,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", "thiserror", @@ -2282,7 +3128,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.12.1", "byteorder", "chrono", "crc", @@ -2319,7 +3165,7 @@ checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.12.0", "form_urlencoded", "futures-channel", "futures-core", @@ -2394,6 +3240,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tcp-stream" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495b0abdce3dc1f8fd27240651c9e68890c14e9d9c61527b1ce44d8a5a7bd3d5" +dependencies = [ + "cfg-if", + "p12-keystore", + "rustls-connector", + "rustls-pemfile", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2489,11 +3347,22 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-executor-trait" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6278565f9fd60c2d205dfbc827e8bb1236c2b1a57148708e95861eff7a6b3bad" +dependencies = [ + "async-trait", + "executor-trait", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -2561,7 +3430,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.12.1", "bytes", "http", "http-body", @@ -2788,6 +3657,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2885,7 +3760,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.12.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2906,6 +3781,28 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2965,13 +3862,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2983,34 +3889,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3023,24 +3962,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3111,7 +4074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.12.1", "indexmap", "log", "serde", @@ -3147,6 +4110,34 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index 97ee9ef..a427341 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ # edition = "2024" [workspace] -members = ["libs/shared", "services/auth-service", "services/url-service"] +members = ["libs/shared", "services/auth-service", "services/url-service", "services/consumer-service"] resolver = "3" # [dependencies] diff --git a/docker-compose.yml b/docker-compose.yml index eba154e..f6feb9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,19 @@ services: + rabbitmq: + image: rabbitmq:4.3.1-management-alpine + container_name: bittuly-rabbitmq + ports: + - "5672:5672" # AMQP protocol port + - "15672:15672" # Management UI port + environment: + RABBITMQ_DEFAULT_USER: bittu + RABBITMQ_DEFAULT_PASS: bittu + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 5 + api-gateway: image: nginx:1.31.1-alpine container_name: bittuly-api-gateway diff --git a/libs/shared/Cargo.toml b/libs/shared/Cargo.toml index b2ad5ef..921678b 100644 --- a/libs/shared/Cargo.toml +++ b/libs/shared/Cargo.toml @@ -14,3 +14,5 @@ redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } +lapin = "2.5.0" +deadpool-lapin = "0.12.1" diff --git a/libs/shared/src/lib.rs b/libs/shared/src/lib.rs index 9a82c6e..cc36993 100644 --- a/libs/shared/src/lib.rs +++ b/libs/shared/src/lib.rs @@ -1,4 +1,8 @@ pub mod config; pub mod jwt; pub mod postgres; +pub mod rabbitmq; pub mod redis; + +pub use deadpool_lapin; +pub use lapin; diff --git a/libs/shared/src/rabbitmq.rs b/libs/shared/src/rabbitmq.rs new file mode 100644 index 0000000..e171200 --- /dev/null +++ b/libs/shared/src/rabbitmq.rs @@ -0,0 +1,34 @@ +use deadpool_lapin::{Config, Pool, Runtime}; + +pub async fn init_rabbitmq_pool(amqp_url: &str) -> Pool { + let mut cfg = Config::default(); + cfg.url = Some(amqp_url.to_string()); + + let pool = cfg + .create_pool(Some(Runtime::Tokio1)) + .expect("Failed to create RabbitMQ pool"); + + if let Ok(conn) = pool.get().await { + if let Ok(channel) = conn.create_channel().await { + let mut options = lapin::options::QueueDeclareOptions::default(); + options.durable = true; + + let _ = channel + .queue_declare( + "user_deleted_queue", + options, + lapin::types::FieldTable::default(), + ) + .await; + let _ = channel + .queue_declare( + "click_events_queue", + options, + lapin::types::FieldTable::default(), + ) + .await; + } + } + + pool +} diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index 9868f16..480635c 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -104,7 +104,25 @@ pub async fn delete_user( } match user_service::delete_user(&state.db, user_id).await { - Ok(_) => StatusCode::NO_CONTENT.into_response(), + Ok(_) => { + // Publish to RabbitMQ + let payload = serde_json::json!({ "user_id": user_id }).to_string(); + if let Ok(conn) = state.rabbitmq.get().await { + if let Ok(channel) = conn.create_channel().await { + let _ = channel + .basic_publish( + "", // default exchange + "user_deleted_queue", + shared::lapin::options::BasicPublishOptions::default(), + payload.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; + } + } + + StatusCode::NO_CONTENT.into_response() + } Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(err.to_string())).into_response(), } } diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index ddaa2a4..22fcadc 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -27,11 +27,15 @@ async fn main() { .init(); let settings = config::AuthConfig::from_env().expect("Failed to load setting from environment"); + let amqp_url = std::env::var("RABBITMQ_URL").expect("RABBITMQ_URL must be set"); + let db = postgres::init_pg_pool(&settings.database_url) .await .expect("Failed to connect to Database"); - let auth_state = Arc::new(models::AuthState::new(db)); + let rabbitmq = shared::rabbitmq::init_rabbitmq_pool(&amqp_url).await; + + let auth_state = Arc::new(models::AuthState::new(db, rabbitmq)); let cors = CorsLayer::new() .allow_origin( diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index 1bdd77b..905d538 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -11,14 +11,16 @@ use validator::Validate; pub struct AuthState { pub db: PgPool, + pub rabbitmq: shared::deadpool_lapin::Pool, #[allow(dead_code)] pub started_at: Instant, } impl AuthState { - pub fn new(db: PgPool) -> Self { + pub fn new(db: PgPool, rabbitmq: shared::deadpool_lapin::Pool) -> Self { Self { db, + rabbitmq, started_at: Instant::now(), } } diff --git a/services/consumer-service/Cargo.toml b/services/consumer-service/Cargo.toml new file mode 100644 index 0000000..0106ee8 --- /dev/null +++ b/services/consumer-service/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "consumer-service" +version = "0.1.0" +edition = "2024" + +[dependencies] +shared = { path = "../../libs/shared" } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] } +sqlx = { version = "0.9.0", features = ["chrono", "postgres", "runtime-tokio", "uuid"] } +redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1" +uuid = { version = "1.23.2", features = ["serde", "v4"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +lapin = "2.5.0" +deadpool-lapin = "0.12.1" +futures-lite = "2.6.0" +dotenvy = "0.15.7" diff --git a/services/consumer-service/src/main.rs b/services/consumer-service/src/main.rs new file mode 100644 index 0000000..a2ddf1d --- /dev/null +++ b/services/consumer-service/src/main.rs @@ -0,0 +1,249 @@ +use ::redis::AsyncCommands; +use futures_lite::stream::StreamExt; +use lapin::{ + Connection, ConnectionProperties, + options::{BasicAckOptions, BasicConsumeOptions, QueueDeclareOptions}, + types::FieldTable, +}; +use serde::Deserialize; +use shared::postgres; +use shared::redis as shared_redis; +use std::collections::HashMap; +use std::env; +use tokio::time::{Duration, interval}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use uuid::Uuid; + +#[derive(Deserialize, Debug)] +struct UserDeletedEvent { + user_id: Uuid, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "consumer_service=info,shared=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let db_url = env::var("URL_DATABASE_URL").expect("URL_DATABASE_URL must be set"); + let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set"); + let amqp_url = env::var("RABBITMQ_URL").expect("RABBITMQ_URL must be set"); + + let db = postgres::init_pg_pool(&db_url) + .await + .expect("Failed to connect to Database"); + let mut redis_conn = shared_redis::init_redis(&redis_url) + .await + .expect("Failed to connect to Redis"); + + tracing::info!("Connecting to RabbitMQ..."); + let rabbit_conn = Connection::connect(&amqp_url, ConnectionProperties::default()) + .await + .expect("Failed to connect to RabbitMQ"); + + let channel = rabbit_conn.create_channel().await?; + + // Declare queues as durable (required by RabbitMQ 4.0+) + let mut options = QueueDeclareOptions::default(); + options.durable = true; + + channel + .queue_declare("click_events_queue", options, FieldTable::default()) + .await?; + + channel + .queue_declare("user_deleted_queue", options, FieldTable::default()) + .await?; + + let addr_port = amqp_url + .split('@') + .last() + .unwrap_or("127.0.0.1:5672") + .split('/') + .next() + .unwrap_or("127.0.0.1:5672"); + + println!("Consumer service listening on {}", addr_port); + tracing::info!("Consumer service started and connected to RabbitMQ."); + + // ─── Task 1: Click Events Consumer ─────────────────────────────────────── + let click_db = db.clone(); + let click_channel = rabbit_conn.create_channel().await?; + let mut click_consumer = click_channel + .basic_consume( + "click_events_queue", + "consumer_service_clicks", + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await?; + + tokio::spawn(async move { + let mut batch: HashMap = HashMap::new(); + let mut total_clicks: u64 = 0; + let mut last_delivery_tag: Option = None; + let mut flush_interval = interval(Duration::from_secs(30)); + flush_interval.tick().await; + + loop { + tokio::select! { + maybe_delivery = click_consumer.next() => { + if let Some(Ok(delivery)) = maybe_delivery { + if let Ok(short_code) = String::from_utf8(delivery.data.clone()) { + *batch.entry(short_code).or_insert(0) += 1; + total_clicks += 1; + + last_delivery_tag = Some(delivery.delivery_tag); + + if total_clicks >= 17 { + match increment_click_counts(&click_db, &batch).await { + Ok(_) => { + let unique_urls: Vec<_> = batch.keys().cloned().collect(); + tracing::info!( + "🚀 [CLICK BATCH] Flushed {} clicks across {} unique URLs (Trigger: Size). Codes: {:?}", + total_clicks, + unique_urls.len(), + unique_urls + ); + if let Some(tag) = last_delivery_tag.take() { + let _ = click_channel.basic_ack(tag, lapin::options::BasicAckOptions { multiple: true }).await; + } + } + Err(e) => { + tracing::error!("Click batch flush failed (DB down?): {}", e); + if let Some(tag) = last_delivery_tag.take() { + let _ = click_channel.basic_nack(tag, lapin::options::BasicNackOptions { multiple: true, requeue: true }).await; + } + } + } + batch.clear(); + total_clicks = 0; + } + } + } + } + _ = flush_interval.tick() => { + if !batch.is_empty() { + match increment_click_counts(&click_db, &batch).await { + Ok(_) => { + let unique_urls: Vec<_> = batch.keys().cloned().collect(); + tracing::info!( + "⏱️ [CLICK BATCH] Flushed {} clicks across {} unique URLs (Trigger: Timer). Codes: {:?}", + total_clicks, + unique_urls.len(), + unique_urls + ); + if let Some(tag) = last_delivery_tag.take() { + let _ = click_channel.basic_ack(tag, lapin::options::BasicAckOptions { multiple: true }).await; + } + } + Err(e) => { + tracing::error!("Click batch flush failed (DB down?): {}", e); + if let Some(tag) = last_delivery_tag.take() { + let _ = click_channel.basic_nack(tag, lapin::options::BasicNackOptions { multiple: true, requeue: true }).await; + } + } + } + batch.clear(); + total_clicks = 0; + } + } + } + } + }); + + // ─── Task 2: User Deleted Events Consumer ──────────────────────────────── + let user_db = db.clone(); + let user_channel = rabbit_conn.create_channel().await?; + let mut user_consumer = user_channel + .basic_consume( + "user_deleted_queue", + "consumer_service_users", + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await?; + + tokio::spawn(async move { + while let Some(Ok(delivery)) = user_consumer.next().await { + if let Ok(event) = serde_json::from_slice::(&delivery.data) { + tracing::info!( + "🗑️ [USER DELETED] Received cleanup event for user_id: {}", + event.user_id + ); + + // Delete from Postgres and get deleted short codes atomically + match sqlx::query_scalar::<_, String>( + "DELETE FROM urls WHERE user_id = $1 RETURNING short_code", + ) + .bind(event.user_id) + .fetch_all(&user_db) + .await + { + Ok(short_codes) => { + tracing::info!( + "✅ [CLEANUP COMPLETE] Wiped {} URLs from DB and Redis for user_id: {}. Affected codes: {:?}", + short_codes.len(), + event.user_id, + short_codes + ); + + // 3. Evict from Redis + for code in short_codes { + let cache_key = format!("url:{}", code); + let _: () = redis_conn.del(&cache_key).await.unwrap_or_default(); + } + + // 4. Manual ACK + let _ = delivery.ack(BasicAckOptions::default()).await; + } + Err(e) => { + tracing::error!( + "Failed to delete URLs for user_id {}: {}", + event.user_id, + e + ); + // Do not ack, so it can be retried or dead-lettered + } + } + } else { + // Invalid format, just ack to discard + let _ = delivery.ack(BasicAckOptions::default()).await; + } + } + }); + + // Block forever so that the services (click consumer and user deletion consumer) keeps running + std::future::pending::<()>().await; + Ok(()) +} + +async fn increment_click_counts( + db: &sqlx::PgPool, + batch: &HashMap, +) -> Result<(), sqlx::Error> { + let (codes, deltas): (Vec, Vec) = batch + .iter() + .map(|(code, &count)| (code.clone(), count as i64)) + .unzip(); + + sqlx::query( + "UPDATE urls \ + SET click_count = click_count + d.delta \ + FROM (SELECT unnest($1::text[]) AS code, \ + unnest($2::bigint[]) AS delta) AS d \ + WHERE urls.short_code = d.code", + ) + .bind(&codes) + .bind(&deltas) + .execute(db) + .await?; + + Ok(()) +} diff --git a/services/url-service/src/consumer.rs b/services/url-service/src/consumer.rs deleted file mode 100644 index cb198ce..0000000 --- a/services/url-service/src/consumer.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::repository as url_repository; -use shared::postgres::DbPool; -use std::collections::HashMap; -use tokio::sync::mpsc::UnboundedReceiver; -use tokio::task::JoinHandle; - -pub fn spawn_consumer(mut rx: UnboundedReceiver, consumer_db: DbPool) -> JoinHandle<()> { - tokio::spawn(async move { - let mut batch: HashMap = HashMap::new(); - let mut total_clicks: u64 = 0; - - let mut flush_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); - // Consume the immediate first tick so the timer starts 30 s from now, - // not from the moment the task spawns. - flush_interval.tick().await; - - loop { - tokio::select! { - // ── Arm 1: new click event from the channel ────────────────── - maybe_code = rx.recv() => { - match maybe_code { - Some(short_code) => { - *batch.entry(short_code).or_insert(0) += 1; - total_clicks += 1; - - if total_clicks >= 17 { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (size trigger)"), - Err(e) => tracing::error!("click batch flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - None => { - // Channel closed (server shutting down) — drain remainder - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (shutdown drain)"), - Err(e) => tracing::error!("click batch final flush failed: {e}"), - } - } - break; - } - } - } - - // ── Arm 2: periodic 30-second flush ────────────────────────── - _ = flush_interval.tick() => { - if !batch.is_empty() { - match url_repository::increment_click_counts(&consumer_db, &batch).await { - Ok(()) => tracing::info!(total_clicks, "click batch flushed (interval trigger)"), - Err(e) => tracing::error!("click batch interval flush failed: {e}"), - } - batch.clear(); - total_clicks = 0; - } - } - } - } - }) -} diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 4f0997e..cf48af0 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -112,8 +112,20 @@ pub async fn get_original_url( if let Some(original_url) = cached { tracing::info!(short_code, "cache hit"); - if let Err(e) = state.tx.send(short_code) { - tracing::warn!("click channel send failed: {e}"); + + // Publish click to RabbitMQ + if let Ok(conn) = state.rabbitmq.get().await { + if let Ok(channel) = conn.create_channel().await { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; + } } return Redirect::temporary(&original_url).into_response(); } @@ -130,8 +142,19 @@ pub async fn get_original_url( tracing::warn!("redis set_ex failed: {e}"); } - if let Err(e) = state.tx.send(short_code) { - tracing::warn!("click channel send failed: {e}"); + // Publish click to RabbitMQ + if let Ok(conn) = state.rabbitmq.get().await { + if let Ok(channel) = conn.create_channel().await { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; + } } Redirect::temporary(&original_url).into_response() } diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index d2f8069..9166c67 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -1,4 +1,3 @@ -mod consumer; mod handlers; mod health; mod models; @@ -14,8 +13,6 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use tokio::sync::mpsc; - #[tokio::main] async fn main() { tracing_subscriber::registry() @@ -33,15 +30,12 @@ async fn main() { let redis = redis::init_redis(&settings.redis_url) .await .expect("Failed to connect to Redis"); + let amqp_url = std::env::var("RABBITMQ_URL").expect("RABBITMQ_URL must be set"); + let rabbitmq = shared::rabbitmq::init_rabbitmq_pool(&amqp_url).await; - // Consumer task — two flush triggers: - // 1. Size : every 17 accumulated click events - // 2. Timer : every 30 seconds (so low-traffic links are never stuck) - let consumer_db = db.clone(); - let (tx, rx) = mpsc::unbounded_channel::(); - let consumer_handler = consumer::spawn_consumer(rx, consumer_db); + // Consumer logic moved to consumer-service (Phase 2 RabbitMQ) let url_state = Arc::new(models::UrlState::new( - tx.clone(), + rabbitmq, redis, db, settings.cors_origin.clone(), @@ -84,8 +78,4 @@ async fn main() { if let Err(err) = axum::serve(listener, app).await { eprintln!("server error: {err}"); } - - // Signal consumer to stop and wait for it to drain - drop(tx); - consumer_handler.await.unwrap(); } diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index edc63c4..c335da1 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -5,15 +5,13 @@ use chrono::Utc; use serde::Serialize; use shared::redis::RedisConn; use sqlx::PgPool; -use tokio::sync::mpsc::UnboundedSender; + use tokio::time::Instant; use uuid::Uuid; -pub type ClickSender = UnboundedSender; - pub struct UrlState { + pub rabbitmq: shared::deadpool_lapin::Pool, pub db: PgPool, - pub tx: ClickSender, pub redis: RedisConn, #[allow(dead_code)] pub started_at: Instant, @@ -21,10 +19,15 @@ pub struct UrlState { } impl UrlState { - pub fn new(tx: ClickSender, redis: RedisConn, db: PgPool, cors_origin: String) -> Self { + pub fn new( + rabbitmq: shared::deadpool_lapin::Pool, + redis: RedisConn, + db: PgPool, + cors_origin: String, + ) -> Self { Self { + rabbitmq, db, - tx, redis, started_at: Instant::now(), cors_origin, diff --git a/services/url-service/src/repository.rs b/services/url-service/src/repository.rs index 6065103..17c33d0 100644 --- a/services/url-service/src/repository.rs +++ b/services/url-service/src/repository.rs @@ -147,30 +147,3 @@ pub async fn delete_url( Ok(row.map(|(short_code,)| short_code)) } - -/// Flushes a batch of (short_code → click delta) into the database. -/// Called only by the consumer task — not exposed through the service layer. -/// Uses a single unnest-based UPDATE — one query, one round-trip, no loop. -pub async fn increment_click_counts( - db: &DbPool, - batch: &HashMap, -) -> Result<(), sqlx::Error> { - let (codes, deltas): (Vec, Vec) = batch - .iter() - .map(|(code, &count)| (code.clone(), count as i64)) - .unzip(); - - sqlx::query( - "UPDATE urls \ - SET click_count = click_count + d.delta \ - FROM (SELECT unnest($1::text[]) AS code, \ - unnest($2::bigint[]) AS delta) AS d \ - WHERE urls.short_code = d.code", - ) - .bind(&codes) - .bind(&deltas) - .execute(db) - .await?; - - Ok(()) -} From cacc3e3bbbe9392bdc613e7ddf58e8cb1330520d Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 15:48:26 +0530 Subject: [PATCH 20/32] fix: config issues and clippy errors --- libs/shared/src/rabbitmq.rs | 46 +++++++++++++----------- services/auth-service/src/handlers.rs | 24 ++++++------- services/consumer-service/src/main.rs | 14 ++++---- services/url-service/src/handlers.rs | 48 +++++++++++++------------- services/url-service/src/repository.rs | 2 -- 5 files changed, 69 insertions(+), 65 deletions(-) diff --git a/libs/shared/src/rabbitmq.rs b/libs/shared/src/rabbitmq.rs index e171200..c64c0b5 100644 --- a/libs/shared/src/rabbitmq.rs +++ b/libs/shared/src/rabbitmq.rs @@ -1,33 +1,37 @@ use deadpool_lapin::{Config, Pool, Runtime}; pub async fn init_rabbitmq_pool(amqp_url: &str) -> Pool { - let mut cfg = Config::default(); - cfg.url = Some(amqp_url.to_string()); + let cfg = Config { + url: Some(amqp_url.to_string()), + ..Default::default() + }; let pool = cfg .create_pool(Some(Runtime::Tokio1)) .expect("Failed to create RabbitMQ pool"); - if let Ok(conn) = pool.get().await { - if let Ok(channel) = conn.create_channel().await { - let mut options = lapin::options::QueueDeclareOptions::default(); - options.durable = true; + if let Ok(conn) = pool.get().await + && let Ok(channel) = conn.create_channel().await + { + let options = lapin::options::QueueDeclareOptions { + durable: true, + ..Default::default() + }; - let _ = channel - .queue_declare( - "user_deleted_queue", - options, - lapin::types::FieldTable::default(), - ) - .await; - let _ = channel - .queue_declare( - "click_events_queue", - options, - lapin::types::FieldTable::default(), - ) - .await; - } + let _ = channel + .queue_declare( + "user_deleted_queue", + options, + lapin::types::FieldTable::default(), + ) + .await; + let _ = channel + .queue_declare( + "click_events_queue", + options, + lapin::types::FieldTable::default(), + ) + .await; } pool diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index 480635c..81ca307 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -107,18 +107,18 @@ pub async fn delete_user( Ok(_) => { // Publish to RabbitMQ let payload = serde_json::json!({ "user_id": user_id }).to_string(); - if let Ok(conn) = state.rabbitmq.get().await { - if let Ok(channel) = conn.create_channel().await { - let _ = channel - .basic_publish( - "", // default exchange - "user_deleted_queue", - shared::lapin::options::BasicPublishOptions::default(), - payload.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; - } + if let Ok(conn) = state.rabbitmq.get().await + && let Ok(channel) = conn.create_channel().await + { + let _ = channel + .basic_publish( + "", // default exchange + "user_deleted_queue", + shared::lapin::options::BasicPublishOptions::default(), + payload.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; } StatusCode::NO_CONTENT.into_response() diff --git a/services/consumer-service/src/main.rs b/services/consumer-service/src/main.rs index a2ddf1d..79374cd 100644 --- a/services/consumer-service/src/main.rs +++ b/services/consumer-service/src/main.rs @@ -50,8 +50,10 @@ async fn main() -> Result<(), Box> { let channel = rabbit_conn.create_channel().await?; // Declare queues as durable (required by RabbitMQ 4.0+) - let mut options = QueueDeclareOptions::default(); - options.durable = true; + let options = QueueDeclareOptions { + durable: true, + ..Default::default() + }; channel .queue_declare("click_events_queue", options, FieldTable::default()) @@ -63,7 +65,7 @@ async fn main() -> Result<(), Box> { let addr_port = amqp_url .split('@') - .last() + .next_back() .unwrap_or("127.0.0.1:5672") .split('/') .next() @@ -94,8 +96,9 @@ async fn main() -> Result<(), Box> { loop { tokio::select! { maybe_delivery = click_consumer.next() => { - if let Some(Ok(delivery)) = maybe_delivery { - if let Ok(short_code) = String::from_utf8(delivery.data.clone()) { + if let Some(Ok(delivery)) = maybe_delivery + && let Ok(short_code) = String::from_utf8(delivery.data.clone()) + { *batch.entry(short_code).or_insert(0) += 1; total_clicks += 1; @@ -124,7 +127,6 @@ async fn main() -> Result<(), Box> { } batch.clear(); total_clicks = 0; - } } } } diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index cf48af0..3e8b73f 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -114,18 +114,18 @@ pub async fn get_original_url( tracing::info!(short_code, "cache hit"); // Publish click to RabbitMQ - if let Ok(conn) = state.rabbitmq.get().await { - if let Ok(channel) = conn.create_channel().await { - let _ = channel - .basic_publish( - "", - "click_events_queue", - shared::lapin::options::BasicPublishOptions::default(), - short_code.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; - } + if let Ok(conn) = state.rabbitmq.get().await + && let Ok(channel) = conn.create_channel().await + { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; } return Redirect::temporary(&original_url).into_response(); } @@ -143,18 +143,18 @@ pub async fn get_original_url( } // Publish click to RabbitMQ - if let Ok(conn) = state.rabbitmq.get().await { - if let Ok(channel) = conn.create_channel().await { - let _ = channel - .basic_publish( - "", - "click_events_queue", - shared::lapin::options::BasicPublishOptions::default(), - short_code.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; - } + if let Ok(conn) = state.rabbitmq.get().await + && let Ok(channel) = conn.create_channel().await + { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; } Redirect::temporary(&original_url).into_response() } diff --git a/services/url-service/src/repository.rs b/services/url-service/src/repository.rs index 17c33d0..f9a4853 100644 --- a/services/url-service/src/repository.rs +++ b/services/url-service/src/repository.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use crate::models::Url; use shared::postgres::DbPool; use uuid::Uuid; From e7f3fd94cced3dadfef7de7648e0717977086140 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 15:58:37 +0530 Subject: [PATCH 21/32] fix shortcode clipboard and update REAME covering Event Bus details --- README.md | 14 +++++++++++--- web/src/components/UrlItem.tsx | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d86746..d7cd08c 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ Bittuly is a production-grade, distributed URL shortener built with Rust (Axum) ## 🏗️ System Architecture - **`auth-service` (Port 3001)**: Handles user signups, stateless OTP verification (via email or console), JWT generation, and user management. -- **`url-service` (Port 3002)**: Handles URL shortening, redirects, and click analytics tracking. Uses Redis for caching short URLs and a background async worker for processing click metrics. -- **`libs/shared`**: Shared Rust crate containing JWT logic, configurations, database connections, and middleware. +- **`url-service` (Port 3002)**: Handles URL shortening, redirects, and click analytics tracking. Uses Redis for caching short URLs. +- **`consumer-service`**: Background asynchronous worker that consumes events from RabbitMQ (e.g., batch-updating clicks, cascading user deletion cleanups). +- **`libs/shared`**: Shared Rust crate containing RabbitMQ configurations, JWT logic, database connections, and middleware. +- **`Event Bus (RabbitMQ)`**: Core messaging backbone providing *At-Least-Once* delivery for reliable, decoupled inter-service communication. - **`web/` (Port 5173)**: Modern React frontend built with Vite, Tailwind CSS, and a beautiful Notion-inspired design system. --- @@ -23,6 +25,7 @@ This spins up: - `postgres-auth` (Port 5432) — Database: `bittuly_auth` - `postgres-urls` (Port 5433) — Database: `bittuly_urls` - `redis` (Port 6379) +- `rabbitmq` (Port 5672) and Management UI (Port 15672) *Note: The Postgres schemas are automatically created via the init scripts in `docker/postgres-auth/init` and `docker/postgres-urls/init` the first time the containers start.* @@ -40,7 +43,7 @@ VITE_URLS_API_URL=http://localhost:3002 ``` ### 3. Start the Microservices -We have set up convenient Cargo aliases using `cargo-watch` so that both services auto-reload on code changes. You will need two separate terminal windows for the backend. +We have set up convenient Cargo aliases using `cargo-watch` so that all services auto-reload on code changes. You will need three separate terminal windows for the backend. *(Ensure you have cargo-watch installed: `cargo install cargo-watch`)* @@ -54,6 +57,11 @@ cargo dev-auth cargo dev-urls ``` +**Terminal 3 (Consumer Service):** +```bash +cargo dev-consumer +``` + ### 4. Start the Frontend Open a third terminal window, navigate to the `web/` directory, install dependencies, and start the Vite development server: diff --git a/web/src/components/UrlItem.tsx b/web/src/components/UrlItem.tsx index bb14591..ddc6ba3 100644 --- a/web/src/components/UrlItem.tsx +++ b/web/src/components/UrlItem.tsx @@ -25,7 +25,24 @@ export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(shortUrl) + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(shortUrl) + } else { + const textArea = document.createElement("textarea") + textArea.value = shortUrl + textArea.style.position = "absolute" + textArea.style.left = "-999999px" + document.body.prepend(textArea) + textArea.select() + try { + document.execCommand("copy") + } catch (error) { + console.error(error) + throw new Error("copy failed") + } finally { + textArea.remove() + } + } setCopied(true) toast.success("Copied to clipboard!") setTimeout(() => setCopied(false), 2000) From 1173e631a850d3c52769cd0b0e05686bd543e100 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 18:19:14 +0530 Subject: [PATCH 22/32] feat: implement native RabbitMQ dead-letter queue and exponential backoff - Added 3 tiered delay queues (3s, 9s, 27s) for user deletion retries via TTL and DLX. - Added permanent dead letter queue (DLQ) for failed events exceeding 3 retries. - Added dynamic local async sleep backoff for high-throughput click batches. --- libs/shared/src/rabbitmq.rs | 35 ++++++++++ services/consumer-service/src/main.rs | 98 ++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/libs/shared/src/rabbitmq.rs b/libs/shared/src/rabbitmq.rs index c64c0b5..22382bc 100644 --- a/libs/shared/src/rabbitmq.rs +++ b/libs/shared/src/rabbitmq.rs @@ -18,6 +18,41 @@ pub async fn init_rabbitmq_pool(amqp_url: &str) -> Pool { ..Default::default() }; + let _ = channel + .queue_declare( + "user_deleted_dlq", + options, + lapin::types::FieldTable::default(), + ) + .await; + + for delay in [3, 9, 27] { + let mut retry_args = lapin::types::FieldTable::default(); + retry_args.insert( + "x-message-ttl".into(), + lapin::types::AMQPValue::LongInt(delay * 1000), + ); + retry_args.insert( + "x-dead-letter-exchange".into(), + lapin::types::AMQPValue::LongString("".into()), + ); + retry_args.insert( + "x-dead-letter-routing-key".into(), + lapin::types::AMQPValue::LongString("user_deleted_queue".into()), + ); + + let _ = channel + .queue_declare( + &format!("user_deleted_retry_{}s", delay), + lapin::options::QueueDeclareOptions { + durable: true, + ..Default::default() + }, + retry_args, + ) + .await; + } + let _ = channel .queue_declare( "user_deleted_queue", diff --git a/services/consumer-service/src/main.rs b/services/consumer-service/src/main.rs index 79374cd..5595bf2 100644 --- a/services/consumer-service/src/main.rs +++ b/services/consumer-service/src/main.rs @@ -55,6 +55,37 @@ async fn main() -> Result<(), Box> { ..Default::default() }; + let _ = channel + .queue_declare("user_deleted_dlq", options, FieldTable::default()) + .await?; + + for delay in [3, 9, 27] { + let mut retry_args = FieldTable::default(); + retry_args.insert( + "x-message-ttl".into(), + lapin::types::AMQPValue::LongInt(delay * 1000), + ); + retry_args.insert( + "x-dead-letter-exchange".into(), + lapin::types::AMQPValue::LongString("".into()), + ); + retry_args.insert( + "x-dead-letter-routing-key".into(), + lapin::types::AMQPValue::LongString("user_deleted_queue".into()), + ); + + let _ = channel + .queue_declare( + &format!("user_deleted_retry_{}s", delay), + QueueDeclareOptions { + durable: true, + ..Default::default() + }, + retry_args, + ) + .await?; + } + channel .queue_declare("click_events_queue", options, FieldTable::default()) .await?; @@ -90,6 +121,7 @@ async fn main() -> Result<(), Box> { let mut batch: HashMap = HashMap::new(); let mut total_clicks: u64 = 0; let mut last_delivery_tag: Option = None; + let mut consecutive_failures = 0; let mut flush_interval = interval(Duration::from_secs(30)); flush_interval.tick().await; @@ -108,6 +140,7 @@ async fn main() -> Result<(), Box> { match increment_click_counts(&click_db, &batch).await { Ok(_) => { let unique_urls: Vec<_> = batch.keys().cloned().collect(); + consecutive_failures = 0; tracing::info!( "🚀 [CLICK BATCH] Flushed {} clicks across {} unique URLs (Trigger: Size). Codes: {:?}", total_clicks, @@ -119,7 +152,10 @@ async fn main() -> Result<(), Box> { } } Err(e) => { - tracing::error!("Click batch flush failed (DB down?): {}", e); + let delay = 3_u64.pow(consecutive_failures.min(2) + 1); + consecutive_failures += 1; + tracing::error!("Click batch flush failed (DB down?). Backing off for {}s. Error: {}", delay, e); + tokio::time::sleep(Duration::from_secs(delay)).await; if let Some(tag) = last_delivery_tag.take() { let _ = click_channel.basic_nack(tag, lapin::options::BasicNackOptions { multiple: true, requeue: true }).await; } @@ -135,6 +171,7 @@ async fn main() -> Result<(), Box> { match increment_click_counts(&click_db, &batch).await { Ok(_) => { let unique_urls: Vec<_> = batch.keys().cloned().collect(); + consecutive_failures = 0; tracing::info!( "⏱️ [CLICK BATCH] Flushed {} clicks across {} unique URLs (Trigger: Timer). Codes: {:?}", total_clicks, @@ -146,7 +183,10 @@ async fn main() -> Result<(), Box> { } } Err(e) => { - tracing::error!("Click batch flush failed (DB down?): {}", e); + let delay = 3_u64.pow(consecutive_failures.min(2) + 1); + consecutive_failures += 1; + tracing::error!("Click batch flush failed (DB down?). Backing off for {}s. Error: {}", delay, e); + tokio::time::sleep(Duration::from_secs(delay)).await; if let Some(tag) = last_delivery_tag.take() { let _ = click_channel.basic_nack(tag, lapin::options::BasicNackOptions { multiple: true, requeue: true }).await; } @@ -211,7 +251,59 @@ async fn main() -> Result<(), Box> { event.user_id, e ); - // Do not ack, so it can be retried or dead-lettered + + let mut retry_count = 0; + if let Some(headers) = delivery.properties.headers() { + if let Some(lapin::types::AMQPValue::LongInt(count)) = + headers.inner().get("x-retry-count") + { + retry_count = *count; + } + } + + if retry_count >= 3 { + tracing::error!( + "Message exceeded 3 retries. Moving to user_deleted_dlq." + ); + let _ = user_channel + .basic_publish( + "", + "user_deleted_dlq", + lapin::options::BasicPublishOptions::default(), + &delivery.data, + delivery.properties.clone(), + ) + .await; + } else { + let delay_secs = 3_i32.pow(retry_count as u32 + 1); + let target_queue = format!("user_deleted_retry_{}s", delay_secs); + + tracing::warn!( + "Retrying message in {}s. Attempt: {}/3. Moving to {}.", + delay_secs, + retry_count + 1, + target_queue + ); + let mut props = delivery.properties.clone(); + let mut headers = props.headers().clone().unwrap_or_default(); + headers.insert( + "x-retry-count".into(), + lapin::types::AMQPValue::LongInt(retry_count + 1), + ); + props = props.with_headers(headers); + + let _ = user_channel + .basic_publish( + "", + &target_queue, + lapin::options::BasicPublishOptions::default(), + &delivery.data, + props, + ) + .await; + } + + let _ = delivery.ack(BasicAckOptions::default()).await; } } } else { From cc816598a632fd2857a17f3f759d774229c80dd8 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 18:22:31 +0530 Subject: [PATCH 23/32] fix clippy error --- services/consumer-service/src/main.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/services/consumer-service/src/main.rs b/services/consumer-service/src/main.rs index 5595bf2..ea1e6f0 100644 --- a/services/consumer-service/src/main.rs +++ b/services/consumer-service/src/main.rs @@ -253,12 +253,11 @@ async fn main() -> Result<(), Box> { ); let mut retry_count = 0; - if let Some(headers) = delivery.properties.headers() { - if let Some(lapin::types::AMQPValue::LongInt(count)) = + if let Some(headers) = delivery.properties.headers() + && let Some(lapin::types::AMQPValue::LongInt(count)) = headers.inner().get("x-retry-count") - { - retry_count = *count; - } + { + retry_count = *count; } if retry_count >= 3 { From 635c09f2202413fab44fca2075860293b174c515 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 23:06:56 +0530 Subject: [PATCH 24/32] update readme and target --- README.md | 35 ++++++++++++++++++++++++++++++++--- TARGET.md | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d7cd08c..ca5e5d8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,26 @@ Bittuly is a production-grade, distributed URL shortener built with Rust (Axum) --- +## 📨 Event Bus Workflows (RabbitMQ) + +To ensure the microservices remain decoupled, highly performant, and reliable, we use RabbitMQ as the central messaging backbone. + +### 1. User Deletion Workflow (`user_deleted_queue`) +When a user deletes their account: +1. **Publisher (`auth-service`)**: Deletes the user from `pg-auth` and publishes a `UserDeletedEvent(user_id)` to RabbitMQ. It immediately returns `204 No Content` to the user. +2. **Consumer (`consumer-service`)**: Asynchronously listens for the event. +3. **Execution**: The consumer connects to `pg-urls` to delete all links belonging to the `user_id` using a `RETURNING short_code` query. It then loops through those codes and evicts them from the **Redis Cache**. +4. **Resilience**: If the `pg-urls` database is temporarily down, the consumer utilizes **Native Exponential Backoff**. It publishes the message to tiered delay queues (3s, 9s, 27s) using RabbitMQ's Time-To-Live (TTL) feature. If it fails 4 times, it is routed to a permanent Dead Letter Queue (`user_deleted_dlq`). + +### 2. Analytics Tracking Workflow (`click_events_queue`) +When a user clicks a shortened URL: +1. **Publisher (`url-service`)**: Responds with a fast `302 Redirect` (from Redis) and immediately fires a `short_code` payload into the RabbitMQ `click_events_queue` in the background. The user never waits for the database. +2. **Consumer (`consumer-service`)**: Pulls the click events off the queue and holds them in an in-memory `HashMap`. +3. **Execution (Batching)**: To prevent hammering the database, the consumer flushes the clicks to Postgres in **bulk** either every 30 seconds (Timer-based) or every 17 clicks (Size-based). It executes a single atomic `UPDATE` using `unnest` arrays. +4. **Resilience**: If the database is offline during a flush, the consumer sleeps its thread (Dynamic Backoff: 3s, 9s, 27s) and `NACK`s the batch, cleanly applying backpressure to the RabbitMQ queue until the DB recovers. + +--- + ## 🚀 Getting Started ### 1. Start the Infrastructure (Databases & Cache) @@ -101,10 +121,19 @@ If you want to run these exact same checks locally before pushing, you can run t ./scripts/check.sh ``` -**Recommended: Set up a Git Pre-Push Hook** -To ensure you never push broken code to GitHub, you can force Git to automatically run this script every time you type `git push`. If the checks fail, the push is aborted. +**Recommended: Set up Git Hooks** + +To ensure your code is always formatted and passes checks before pushing, we recommend setting up two git hooks: + +**1. Pre-Commit Hook (Auto-formatting)** +This hook automatically runs `cargo fmt` to format your Rust code right before creating a commit. +```bash +echo -e '#!/bin/sh\n\necho "==> Formatting Rust code (cargo fmt)..."\ncargo fmt --workspace\n\necho "==> Rust formatting complete."\n' > .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` -To install the hook, simply run: +**2. Pre-Push Hook (CI Checks)** +This hook runs the full check script before pushing code to GitHub. If tests or linting fail, the push is aborted. ```bash cp scripts/check.sh .git/hooks/pre-push chmod +x .git/hooks/pre-push diff --git a/TARGET.md b/TARGET.md index 0bb3c73..5ec5b48 100644 --- a/TARGET.md +++ b/TARGET.md @@ -504,44 +504,44 @@ Each service exposes `GET /metrics` using the `metrics` + `metrics-exporter-prom --- -### Phase 0 — Workspace Restructure +### ✅ Phase 0 — Workspace Restructure **Goal:** Convert monolith repo into Cargo workspace. No functional change. -- [ ] Create workspace `Cargo.toml` with `[workspace]` members -- [ ] Create `libs/shared/` crate — move JWT types, config, errors into it -- [ ] Create `services/auth-service/` skeleton — copy auth handlers/models/repos -- [ ] Create `services/url-service/` skeleton — copy url handlers/models/repos -- [ ] Both services compile independently with `shared` as a dependency -- [ ] `docker-compose.yml` builds both services, both start successfully -- [ ] All existing functionality works end-to-end +- [x] Create workspace `Cargo.toml` with `[workspace]` members +- [x] Create `libs/shared/` crate — move JWT types, config, errors into it +- [x] Create `services/auth-service/` skeleton — copy auth handlers/models/repos +- [x] Create `services/url-service/` skeleton — copy url handlers/models/repos +- [x] Both services compile independently with `shared` as a dependency +- [x] `docker-compose.yml` builds both services, both start successfully +- [x] All existing functionality works end-to-end --- -### Phase 1 — Service Extraction & DB Split +### ✅ Phase 1 — Service Extraction & DB Split **Goal:** Two fully independent services with separate databases. -- [ ] `pg-auth`: users table only -- [ ] `pg-urls`: urls table only, `user_id` is a plain UUID column (no FK) -- [ ] `auth-service`: issues JWTs; validates via `/api/auth/validate` -- [ ] `url-service`: reads `X-User-Id` header injected by gateway (no JWT parsing) -- [ ] Docker Compose: add simple NGINX or Caddy as reverse proxy for local dev -- [ ] Frontend routes unchanged — still talks to `:3000` -- [ ] Full end-to-end test: signup → login → shorten → redirect → delete +- [x] `pg-auth`: users table only +- [x] `pg-urls`: urls table only, `user_id` is a plain UUID column (no FK) +- [x] `auth-service`: issues JWTs; validates via `/api/auth/validate` +- [x] `url-service`: reads `X-User-Id` header injected by gateway (no JWT parsing) +- [x] Docker Compose: add simple NGINX or Caddy as reverse proxy for local dev +- [x] Frontend routes unchanged — still talks to `:3000` (or proxy) +- [x] Full end-to-end test: signup → login → shorten → redirect → delete --- -### Phase 2 — RabbitMQ Event Bus +### ✅ Phase 2 — RabbitMQ Event Bus **Goal:** Reliable cross-service event delivery with durability guarantees. -- [ ] Add RabbitMQ to Docker Compose (management UI on :15672) -- [ ] Add `lapin` + `deadpool-lapin` to both services via shared crate -- [ ] `auth-service` publishes `user.deleted` on account deletion -- [ ] `url-service` consumes `user.deleted`: +- [x] Add RabbitMQ to Docker Compose (management UI on :15672) +- [x] Add `lapin` + `deadpool-lapin` to both services via shared crate +- [x] `auth-service` publishes `user.deleted` on account deletion +- [x] Background consumer consumes `user.deleted`: - Deletes all URLs for that user_id - Evicts their short_codes from Redis - Manual ack after successful DB + Redis operations -- [ ] Dead-letter queue for failed messages -- [ ] Integration test: delete account → verify URLs gone, Redis keys evicted +- [x] Dead-letter queue and Exponential Backoff (3s, 9s, 27s) for failed messages +- [x] Integration test: delete account → verify URLs gone, Redis keys evicted --- @@ -709,4 +709,4 @@ open http://localhost:16686 # Jaeger --- *Document created: 2026-06-13* -*Current status: Phase 0 — Workspace Restructure — NOT STARTED* +*Current status: Phase 3 — URL Expiration — IN PROGRESS* From 27dea43ca9eda7b83a7e2ae10a1b59917a0e7c41 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Mon, 15 Jun 2026 23:32:50 +0530 Subject: [PATCH 25/32] add IP based rate limitting at /{shortcode} path --- TARGET.md | 4 ++-- docker/nginx/nginx.conf | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/TARGET.md b/TARGET.md index 5ec5b48..13e626a 100644 --- a/TARGET.md +++ b/TARGET.md @@ -447,8 +447,8 @@ Each service exposes `GET /metrics` using the `metrics` + `metrics-exporter-prom |---|---| | TLS | cert-manager + Let's Encrypt, HTTPS-only, HTTP → HTTPS redirect | | JWT validation | NGINX `auth_request` → `auth-service /api/auth/validate` | -| Rate limiting — redirects | 100 req/s per IP (`limit-rps: "100"`) | -| Rate limiting — shorten | 10 req/min per user (`limit-rpm: "10"` + X-User-Id key) | +| Rate limiting — redirects | 20 req/s per IP | +| Rate limiting — shorten | 10 req/min per user (X-User-Id key) | | URL safety | Google Safe Browsing API v4, checked before every INSERT | | Secrets | Kubernetes Secrets + Sealed Secrets operator (safe to git-commit) | | Network policy | Deny-all default, allow-list per service | diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index d419358..b46c931 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -9,6 +9,11 @@ http { default_type application/octet-stream; sendfile on; + # Rate limiting zone for IP addresses + # 100MB memory can efficiently track ~1.6 million unique IP addresses simultaneously + limit_req_zone $binary_remote_addr zone=ip_limit:200m rate=20r/s; + limit_req_status 429; # Return 429 Too Many Requests instead of default 503 + server { listen 8000; server_name localhost; @@ -41,7 +46,7 @@ http { # Known frontend routes and assets location ~ ^/(dashboard|login|signup|verify-otp|profile|settings|insights|health|unavailable|assets|src|node_modules|@fs|@vite|@react-refresh|__vite_ping|favicon.ico|manifest.json) { proxy_pass http://127.0.0.1:5173; - + # WebSocket support for Vite HMR proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -51,9 +56,11 @@ http { # ----------------------------------------- # 3. Short URLs (Catch-all) # ----------------------------------------- - # Any path that isn't an API or known Frontend route is assumed + # Any path that isn't an API or known Frontend route is assumed # to be a short code and goes to the URL service. location / { + # Apply rate limiting: allow bursts up to 20 requests without artificial delay + limit_req zone=ip_limit burst=20 nodelay; proxy_pass http://127.0.0.1:3002; } } From 4016b46262e5c93949b031f9891684266f00c1d7 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 00:16:11 +0530 Subject: [PATCH 26/32] add url expiration time and add a date&time picker in frontend --- docker/postgres-urls/init/01-init.sql | 2 + services/url-service/src/handlers.rs | 28 ++++++- services/url-service/src/models.rs | 1 + services/url-service/src/repository.rs | 52 +++++++++---- services/url-service/src/services.rs | 5 +- web/src/api/urls.ts | 5 +- web/src/pages/Dashboard.tsx | 104 ++++++++++++++++++------- 7 files changed, 145 insertions(+), 52 deletions(-) diff --git a/docker/postgres-urls/init/01-init.sql b/docker/postgres-urls/init/01-init.sql index efceabd..bedcc06 100644 --- a/docker/postgres-urls/init/01-init.sql +++ b/docker/postgres-urls/init/01-init.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS urls ( original_url TEXT NOT NULL, user_id UUID NOT NULL, click_count BIGINT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(original_url, user_id) @@ -15,3 +16,4 @@ CREATE TABLE IF NOT EXISTS urls ( CREATE INDEX IF NOT EXISTS idx_urls_user_id ON urls(user_id, url_id DESC); CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code); CREATE INDEX IF NOT EXISTS idx_urls_original_url_trgm ON urls USING GIN (original_url gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at); diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 3e8b73f..1dd0d79 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -70,6 +70,7 @@ pub async fn get_all_urls( pub struct ShortenUrlRequest { #[validate(url)] pub original_url: String, + pub expires_at: Option>, } pub async fn shorten_url( @@ -80,7 +81,8 @@ pub async fn shorten_url( if let Err(errors) = body.validate() { return (StatusCode::UNPROCESSABLE_ENTITY, Json(errors.to_string())).into_response(); } - match url_service::shorten_url(&state.db, &body.original_url, claims.sub).await { + match url_service::shorten_url(&state.db, &body.original_url, claims.sub, body.expires_at).await + { Ok(Some(url)) => (StatusCode::CREATED, Json(url)).into_response(), Ok(None) => ( StatusCode::CONFLICT, @@ -133,10 +135,28 @@ pub async fn get_original_url( // ── 2. Cache miss — query DB ─────────────────────────────────────────── tracing::info!(short_code, "cache miss"); match url_service::get_original_url(&state.db, &short_code).await { - Ok(Some(original_url)) => { - // Populate cache with 24 h TTL, non-fatal if Redis is down + Ok(Some((original_url, expires_at))) => { + // Check if expired! + if let Some(exp) = expires_at { + if exp < chrono::Utc::now() { + return StatusCode::GONE.into_response(); + } + } + + // Dynamic Redis TTL + let ttl: u64 = if let Some(exp) = expires_at { + let remaining = exp.signed_duration_since(chrono::Utc::now()).num_seconds(); + if remaining <= 0 { + return StatusCode::GONE.into_response(); // Just in case + } + remaining.try_into().unwrap_or(60 * 60 * 24) + } else { + 60 * 60 * 24 + }; + + // Populate cache with dynamic TTL, non-fatal if Redis is down if let Err(e) = redis - .set_ex::<_, _, ()>(&short_code, &original_url, 60 * 60 * 24) + .set_ex::<_, _, ()>(&short_code, &original_url, ttl) .await { tracing::warn!("redis set_ex failed: {e}"); diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index c335da1..2ee45ce 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -44,6 +44,7 @@ pub struct Url { pub original_url: String, pub user_id: Uuid, pub click_count: i64, + pub expires_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/services/url-service/src/repository.rs b/services/url-service/src/repository.rs index f9a4853..8d8ef54 100644 --- a/services/url-service/src/repository.rs +++ b/services/url-service/src/repository.rs @@ -10,27 +10,49 @@ pub async fn add_shorten_url( db: &DbPool, original_url: &str, user_id: Uuid, + expires_at: Option>, ) -> Result, sqlx::Error> { - // Pre-check: avoid advancing the sequence on a duplicate - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM urls WHERE original_url = $1 AND user_id = $2)", + // Pre-check: see if the user already shortened this URL + let existing_url: Option = sqlx::query_as( + "SELECT url_id, short_code, original_url, user_id, click_count, expires_at, created_at, updated_at + FROM urls WHERE original_url = $1 AND user_id = $2" ) .bind(original_url) .bind(user_id) - .fetch_one(db) + .fetch_optional(db) .await?; - if exists { + if let Some(existing) = existing_url { + // If it exists, check if it's currently EXPIRED + if let Some(exp) = existing.expires_at { + if exp < chrono::Utc::now() { + // It's expired! Reactivate it by updating `expires_at` + let reactivated: Url = sqlx::query_as( + "UPDATE urls SET expires_at = $1, updated_at = now() + WHERE url_id = $2 + RETURNING url_id, short_code, original_url, user_id, click_count, expires_at, created_at, updated_at" + ) + .bind(expires_at) + .bind(existing.url_id) + .fetch_one(db) + .await?; + + return Ok(Some(reactivated)); + } + } + + // It exists and is NOT expired -> Return Conflict return Ok(None); } let mut tx = db.begin().await?; let url_id: i64 = match sqlx::query_scalar( - "INSERT INTO urls (original_url, user_id) VALUES ($1, $2) RETURNING url_id", + "INSERT INTO urls (original_url, user_id, expires_at) VALUES ($1, $2, $3) RETURNING url_id", ) .bind(original_url) .bind(user_id) + .bind(expires_at) .fetch_one(&mut *tx) .await { @@ -47,7 +69,7 @@ pub async fn add_shorten_url( let url = sqlx::query_as( "UPDATE urls SET short_code = $1 WHERE url_id = $2 \ - RETURNING url_id, short_code, original_url, user_id, click_count, created_at, updated_at", + RETURNING url_id, short_code, original_url, user_id, click_count, expires_at, created_at, updated_at", ) .bind(&short_code) .bind(url_id) @@ -62,13 +84,15 @@ pub async fn add_shorten_url( pub async fn get_original_url( db: &DbPool, short_code: &str, -) -> Result, sqlx::Error> { - let original_url = sqlx::query_scalar("SELECT original_url FROM urls WHERE short_code = $1") - .bind(short_code) - .fetch_optional(db) - .await?; +) -> Result>)>, sqlx::Error> { + let row = sqlx::query_as::<_, (String, Option>)>( + "SELECT original_url, expires_at FROM urls WHERE short_code = $1", + ) + .bind(short_code) + .fetch_optional(db) + .await?; - Ok(original_url) + Ok(row) } /// One page of URLs for a user. @@ -98,7 +122,7 @@ pub async fn get_urls_page( let search_pattern = search.map(|s| format!("%{}%", s)); let rows: Vec = sqlx::query_as( - "SELECT url_id, short_code, original_url, user_id, click_count, created_at, updated_at + "SELECT url_id, short_code, original_url, user_id, click_count, expires_at, created_at, updated_at FROM urls WHERE user_id = $1 AND ($2::bigint IS NULL OR url_id < $2) diff --git a/services/url-service/src/services.rs b/services/url-service/src/services.rs index cb6981b..5e86b76 100644 --- a/services/url-service/src/services.rs +++ b/services/url-service/src/services.rs @@ -6,14 +6,15 @@ pub async fn shorten_url( db: &DbPool, original_url: &str, user_id: Uuid, + expires_at: Option>, ) -> Result, sqlx::Error> { - url_repository::add_shorten_url(db, original_url, user_id).await + url_repository::add_shorten_url(db, original_url, user_id, expires_at).await } pub async fn get_original_url( db: &DbPool, short_code: &str, -) -> Result, sqlx::Error> { +) -> Result>)>, sqlx::Error> { url_repository::get_original_url(db, short_code).await } diff --git a/web/src/api/urls.ts b/web/src/api/urls.ts index 287857b..1e94b05 100644 --- a/web/src/api/urls.ts +++ b/web/src/api/urls.ts @@ -5,6 +5,7 @@ export interface ShortenedUrl { short_code: string original_url: string click_count: number + expires_at: string | null created_at: string } @@ -13,10 +14,10 @@ export interface UrlsPage { next_cursor: string | null } -export async function createUrl(original_url: string): Promise { +export async function createUrl(original_url: string, expires_at?: string): Promise { return apiRequest(URLS_BASE_URL, "/api/urls/", { method: "POST", - body: JSON.stringify({ original_url }), + body: JSON.stringify({ original_url, expires_at }), }) } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 381f417..caa5d28 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { toast } from "sonner" -import { Link2, Plus, ChevronsDown, Search } from "lucide-react" +import { Timer, Link2, Plus, ChevronsDown, Search } from "lucide-react" import { createUrl, deleteUrl, getUrlsPage, type ShortenedUrl } from "@/api/urls" import { AppLayout } from "@/components/AppLayout" import { UrlItem } from "@/components/UrlItem" @@ -22,6 +22,8 @@ export function Dashboard() { const [urls, setUrls] = React.useState([]) const [newIds, setNewIds] = React.useState>(new Set()) const [inputUrl, setInputUrl] = React.useState("") + const [expiresAt, setExpiresAt] = React.useState("") + const [showExpiration, setShowExpiration] = React.useState(false) const [inputError, setInputError] = React.useState(false) const [isShortening, setIsShortening] = React.useState(false) const [isLoadingUrls, setIsLoadingUrls] = React.useState(true) @@ -77,11 +79,14 @@ export function Dashboard() { setInputError(false) setIsShortening(true) try { - const created = await createUrl(trimmed) + const expiryIso = expiresAt ? new Date(expiresAt).toISOString() : undefined + const created = await createUrl(trimmed, expiryIso) // Prepend new URL — doesn't disturb pagination cursor setUrls((prev) => [created, ...prev]) setNewIds((prev) => new Set(prev).add(created.id)) setInputUrl("") + setExpiresAt("") + setShowExpiration(false) setTimeout(() => { setNewIds((prev) => { const next = new Set(prev) @@ -127,35 +132,74 @@ export function Dashboard() {

Shorten a new URL

-
-
- { - setInputUrl(e.target.value) - if (inputError) setInputError(false) - }} - aria-invalid={inputError} - className={inputError ? "animate-shake" : ""} - /> - {inputError && ( -

- Please enter a valid URL. -

- )} + +
+
+ { + setInputUrl(e.target.value) + if (inputError) setInputError(false) + }} + aria-invalid={inputError} + className={inputError ? "animate-shake" : ""} + /> + {inputError && ( +

+ Please enter a valid URL. +

+ )} +
+
- + + {showExpiration ? ( +
+ + Expires at: + setExpiresAt(e.target.value)} + className="w-auto h-8 text-xs bg-muted/50" + /> + +
+ ) : ( +
+ +
+ )}
From 73a52c362ff702e77034b67900d116ad9cea5978 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 00:18:20 +0530 Subject: [PATCH 27/32] fix clippy errors --- services/url-service/src/handlers.rs | 8 ++++---- services/url-service/src/repository.rs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 1dd0d79..623a07c 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -137,10 +137,10 @@ pub async fn get_original_url( match url_service::get_original_url(&state.db, &short_code).await { Ok(Some((original_url, expires_at))) => { // Check if expired! - if let Some(exp) = expires_at { - if exp < chrono::Utc::now() { - return StatusCode::GONE.into_response(); - } + if let Some(exp) = expires_at + && exp < chrono::Utc::now() + { + return StatusCode::GONE.into_response(); } // Dynamic Redis TTL diff --git a/services/url-service/src/repository.rs b/services/url-service/src/repository.rs index 8d8ef54..0018d12 100644 --- a/services/url-service/src/repository.rs +++ b/services/url-service/src/repository.rs @@ -24,12 +24,13 @@ pub async fn add_shorten_url( if let Some(existing) = existing_url { // If it exists, check if it's currently EXPIRED - if let Some(exp) = existing.expires_at { - if exp < chrono::Utc::now() { - // It's expired! Reactivate it by updating `expires_at` - let reactivated: Url = sqlx::query_as( - "UPDATE urls SET expires_at = $1, updated_at = now() - WHERE url_id = $2 + if let Some(exp) = existing.expires_at + && exp < chrono::Utc::now() + { + // It's expired! Reactivate it by updating `expires_at` + let reactivated: Url = sqlx::query_as( + "UPDATE urls SET expires_at = $1, updated_at = now() + WHERE url_id = $2 RETURNING url_id, short_code, original_url, user_id, click_count, expires_at, created_at, updated_at" ) .bind(expires_at) @@ -37,8 +38,7 @@ pub async fn add_shorten_url( .fetch_one(db) .await?; - return Ok(Some(reactivated)); - } + return Ok(Some(reactivated)); } // It exists and is NOT expired -> Return Conflict From 598f798cce8e5c8b604ba9c32c39038e4af360cd Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 01:00:42 +0530 Subject: [PATCH 28/32] set shortened url's expiry date and time and update the frontend --- TARGET.md | 12 +- docker/nginx/nginx.conf | 12 ++ services/url-service/src/handlers.rs | 5 +- web/src/components/UrlItem.tsx | 238 ++++++++++++++++++--------- web/src/pages/Unavailable.tsx | 2 +- 5 files changed, 182 insertions(+), 87 deletions(-) diff --git a/TARGET.md b/TARGET.md index 13e626a..4a67956 100644 --- a/TARGET.md +++ b/TARGET.md @@ -548,12 +548,12 @@ Each service exposes `GET /metrics` using the `metrics` + `metrics-exporter-prom ### Phase 3 — URL Expiration **Goal:** URLs can have a TTL; expired URLs return 410 Gone. -- [ ] Add `expires_at TIMESTAMPTZ` column to pg-urls (nullable) -- [ ] `POST /api/urls` body accepts optional `expires_at` RFC3339 timestamp -- [ ] Redirect handler: if `expires_at < NOW()` → 410 Gone (check DB, skip Redis) -- [ ] Redis TTL = `min(expires_at - now(), 24h)` instead of hardcoded 24h -- [ ] Background sweeper task: every 5 minutes, delete expired URLs, publish `url.expired` -- [ ] Frontend: date picker on shorten form; expired links shown with visual indicator +- [x] Add `expires_at TIMESTAMPTZ` column to pg-urls (nullable) +- [x] `POST /api/urls` body accepts optional `expires_at` RFC3339 timestamp +- [x] Redirect handler: if `expires_at < NOW()` → 410 Gone (check DB, skip Redis) +- [x] Redis TTL = `min(expires_at - now(), 24h)` instead of hardcoded 24h +- [x] Background sweeper task: every 5 minutes, delete expired URLs, publish `url.expired` +- [x] Frontend: date picker on shorten form; expired links shown with visual indicator --- diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index b46c931..fb66667 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -62,6 +62,18 @@ http { # Apply rate limiting: allow bursts up to 20 requests without artificial delay limit_req zone=ip_limit burst=20 nodelay; proxy_pass http://127.0.0.1:3002; + + # Intercept 404 (Not Found) and 410 (Gone) from the backend + proxy_intercept_errors on; + error_page 404 410 =200 @frontend; + } + + # Interception fallback to render the React app at the current URL + location @frontend { + proxy_pass http://127.0.0.1:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } } } diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 623a07c..46ef1aa 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -136,7 +136,6 @@ pub async fn get_original_url( tracing::info!(short_code, "cache miss"); match url_service::get_original_url(&state.db, &short_code).await { Ok(Some((original_url, expires_at))) => { - // Check if expired! if let Some(exp) = expires_at && exp < chrono::Utc::now() { @@ -178,9 +177,7 @@ pub async fn get_original_url( } Redirect::temporary(&original_url).into_response() } - Ok(None) => { - Redirect::temporary(&format!("{}/unavailable", state.cors_origin)).into_response() - } + Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(err) => { tracing::error!("{:?}", err); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/web/src/components/UrlItem.tsx b/web/src/components/UrlItem.tsx index ddc6ba3..cc02ae8 100644 --- a/web/src/components/UrlItem.tsx +++ b/web/src/components/UrlItem.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Copy, Check, Trash2, ExternalLink, BarChart2, X } from "lucide-react" +import { Copy, Check, Trash2, ExternalLink, BarChart2, X, Timer } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -8,6 +8,23 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import type { ShortenedUrl } from "@/api/urls" import { URLS_BASE_URL } from "@/api/client" @@ -19,76 +36,73 @@ interface UrlItemProps { export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { const [copied, setCopied] = React.useState(false) - const [confirming, setConfirming] = React.useState(false) - const confirmTimerRef = React.useRef | null>(null) + const [deleteOpen, setDeleteOpen] = React.useState(false) + const [detailsOpen, setDetailsOpen] = React.useState(false) const shortUrl = `${URLS_BASE_URL}/${url.short_code}` - const handleCopy = async () => { - try { + const isExpired = url.expires_at ? new Date(url.expires_at) < new Date() : false + const formattedExpiry = url.expires_at + ? new Date(url.expires_at).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) + : null + + const handleCopy = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + const executeCopy = () => { if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(shortUrl) + return navigator.clipboard.writeText(shortUrl) } else { + // Fallback for insecure HTTP contexts const textArea = document.createElement("textarea") textArea.value = shortUrl - textArea.style.position = "absolute" + textArea.style.position = "fixed" // Prevent scrolling to bottom textArea.style.left = "-999999px" - document.body.prepend(textArea) + textArea.style.top = "0" + document.body.appendChild(textArea) + textArea.focus() textArea.select() try { - document.execCommand("copy") + const successful = document.execCommand("copy") + if (!successful) throw new Error("copy command failed") + return Promise.resolve() } catch (error) { - console.error(error) - throw new Error("copy failed") + console.error("Fallback copy error:", error) + return Promise.reject(error) } finally { textArea.remove() } } - setCopied(true) - toast.success("Copied to clipboard!") - setTimeout(() => setCopied(false), 2000) - } catch { - toast.error("Failed to copy") } - } - const handleDeleteClick = () => { - if (!confirming) { - // First click — enter confirm mode, auto-reset after 3s - setConfirming(true) - confirmTimerRef.current = setTimeout(() => setConfirming(false), 3000) - } else { - // Second click — execute - if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current) - setConfirming(false) - onDelete?.(url.id) - } + executeCopy() + .then(() => { + setCopied(true) + toast.success("Copied to clipboard!") + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + toast.error("Failed to copy shortcode") + }) } - const handleCancelDelete = (e: React.MouseEvent) => { - e.stopPropagation() - if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current) - setConfirming(false) - } - // Cleanup timer on unmount - React.useEffect(() => { - return () => { - if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current) - } - }, []) return ( - -
+ + + + {/* Expiry */} + {formattedExpiry && ( +
+ + {isExpired ? "Expired" : formattedExpiry} +
+ )} + {/* Actions */}
{/* Copy */} @@ -137,45 +159,109 @@ export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { {copied ? "Copied!" : "Copy link"} - {/* Delete — two-step inline confirm */} - {confirming ? ( - <> - - - - ) : ( + {/* Delete — Modal confirmation */} + - + + + Delete - )} + + + + Delete this short link? + + This will permanently delete the shortened link (bittuly.com/{url.short_code}) and remove all its click analytics. This action cannot be undone. + + + + Cancel + onDelete?.(url.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete Link + + + +
+ + + + Link Details + +
+
+ Original URL +

{url.original_url}

+
+
+ Short Link +
+ + {shortUrl} + + +
+
+
+
+ Total Clicks +

{url.click_count}

+
+
+ Created on +

{new Date(url.created_at).toLocaleDateString([], { dateStyle: 'medium' })}

+
+
+ {formattedExpiry && ( +
+ Expiration Status +
+ + {isExpired ? "Expired" : "Active"} + + + (Expires: {formattedExpiry}) + +
+
+ )} +
+ + {/* Footer with Delete Action */} +
+ + +
+ + ) } diff --git a/web/src/pages/Unavailable.tsx b/web/src/pages/Unavailable.tsx index bbad10f..fef43de 100644 --- a/web/src/pages/Unavailable.tsx +++ b/web/src/pages/Unavailable.tsx @@ -12,7 +12,7 @@ export function Unavailable() {

Link Unavailable

- The short link you clicked does not exist or has been deleted by its owner. + The short link you clicked does not exist, has expired, or has been deleted by its owner.

From 913754e9505edddba96a5cb501864bd11287866d Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 01:06:36 +0530 Subject: [PATCH 29/32] fix clippy errror --- services/url-service/src/models.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index 2ee45ce..6c55223 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -15,6 +15,7 @@ pub struct UrlState { pub redis: RedisConn, #[allow(dead_code)] pub started_at: Instant, + #[allow(dead_code)] pub cors_origin: String, } From 9597737b36e93a906a58908c543e20f71f30d173 Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 01:07:47 +0530 Subject: [PATCH 30/32] fix typscript error --- web/src/components/UrlItem.tsx | 435 ++++++++++++++++++--------------- 1 file changed, 242 insertions(+), 193 deletions(-) diff --git a/web/src/components/UrlItem.tsx b/web/src/components/UrlItem.tsx index cc02ae8..6f7e18e 100644 --- a/web/src/components/UrlItem.tsx +++ b/web/src/components/UrlItem.tsx @@ -1,13 +1,20 @@ -import * as React from "react" -import { Copy, Check, Trash2, ExternalLink, BarChart2, X, Timer } from "lucide-react" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" +import * as React from "react"; +import { + Copy, + Check, + Trash2, + ExternalLink, + BarChart2, + Timer, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "@/components/ui/tooltip" +} from "@/components/ui/tooltip"; import { AlertDialog, AlertDialogAction, @@ -18,75 +25,78 @@ import { AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, -} from "@/components/ui/alert-dialog" +} from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" -import type { ShortenedUrl } from "@/api/urls" -import { URLS_BASE_URL } from "@/api/client" +} from "@/components/ui/dialog"; +import type { ShortenedUrl } from "@/api/urls"; +import { URLS_BASE_URL } from "@/api/client"; interface UrlItemProps { - url: ShortenedUrl - isNew?: boolean - onDelete?: (id: number) => void + url: ShortenedUrl; + isNew?: boolean; + onDelete?: (id: number) => void; } export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { - const [copied, setCopied] = React.useState(false) - const [deleteOpen, setDeleteOpen] = React.useState(false) - const [detailsOpen, setDetailsOpen] = React.useState(false) - const shortUrl = `${URLS_BASE_URL}/${url.short_code}` + const [copied, setCopied] = React.useState(false); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [detailsOpen, setDetailsOpen] = React.useState(false); + const shortUrl = `${URLS_BASE_URL}/${url.short_code}`; - const isExpired = url.expires_at ? new Date(url.expires_at) < new Date() : false - const formattedExpiry = url.expires_at - ? new Date(url.expires_at).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) - : null + const isExpired = url.expires_at + ? new Date(url.expires_at) < new Date() + : false; + const formattedExpiry = url.expires_at + ? new Date(url.expires_at).toLocaleString([], { + dateStyle: "short", + timeStyle: "short", + }) + : null; const handleCopy = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); const executeCopy = () => { if (navigator.clipboard && window.isSecureContext) { - return navigator.clipboard.writeText(shortUrl) + return navigator.clipboard.writeText(shortUrl); } else { // Fallback for insecure HTTP contexts - const textArea = document.createElement("textarea") - textArea.value = shortUrl - textArea.style.position = "fixed" // Prevent scrolling to bottom - textArea.style.left = "-999999px" - textArea.style.top = "0" - document.body.appendChild(textArea) - textArea.focus() - textArea.select() + const textArea = document.createElement("textarea"); + textArea.value = shortUrl; + textArea.style.position = "fixed"; // Prevent scrolling to bottom + textArea.style.left = "-999999px"; + textArea.style.top = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); try { - const successful = document.execCommand("copy") - if (!successful) throw new Error("copy command failed") - return Promise.resolve() + const successful = document.execCommand("copy"); + if (!successful) throw new Error("copy command failed"); + return Promise.resolve(); } catch (error) { - console.error("Fallback copy error:", error) - return Promise.reject(error) + console.error("Fallback copy error:", error); + return Promise.reject(error); } finally { - textArea.remove() + textArea.remove(); } } - } + }; executeCopy() .then(() => { - setCopied(true) - toast.success("Copied to clipboard!") - setTimeout(() => setCopied(false), 2000) + setCopied(true); + toast.success("Copied to clipboard!"); + setTimeout(() => setCopied(false), 2000); }) .catch(() => { - toast.error("Failed to copy shortcode") - }) - } - - + toast.error("Failed to copy shortcode"); + }); + }; return ( @@ -97,171 +107,210 @@ export function UrlItem({ url, isNew = false, onDelete }: UrlItemProps) { isNew ? "animate-in slide-in-from-top-2 fade-in duration-200" : "" }`} > - {/* Short code link */} - e.stopPropagation()} - className="flex min-w-0 shrink-0 items-center gap-1.5 text-sm font-medium text-notion-blue hover:underline" - > - {url.short_code} - - - - {/* Divider */} -
- - {/* Original URL */} - - - - {url.original_url} - - - - {url.original_url} - - - - {/* Click count */} -
- - {url.click_count} {url.click_count === 1 ? "click" : "clicks"} -
+ {/* Short code link */} + e.stopPropagation()} + className="flex min-w-0 shrink-0 items-center gap-1.5 text-sm font-medium text-notion-blue hover:underline" + > + {url.short_code} + + - {/* Expiry */} - {formattedExpiry && ( -
- - {isExpired ? "Expired" : formattedExpiry} -
- )} + {/* Divider */} +
- {/* Actions */} -
- {/* Copy */} + {/* Original URL */} - + + {url.original_url} + - {copied ? "Copied!" : "Copy link"} + + {url.original_url} + - {/* Delete — Modal confirmation */} - + {/* Click count */} +
+ + + {url.click_count} {url.click_count === 1 ? "click" : "clicks"} + +
+ + {/* Expiry */} + {formattedExpiry && ( +
+ + {isExpired ? "Expired" : formattedExpiry} +
+ )} + + {/* Actions */} +
+ {/* Copy */} - - - + - Delete + + {copied ? "Copied!" : "Copy link"} + - - - Delete this short link? - - This will permanently delete the shortened link (bittuly.com/{url.short_code}) and remove all its click analytics. This action cannot be undone. - - - - Cancel - onDelete?.(url.id)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete Link - - - - -
-
+ {/* Delete — Modal confirmation */} + + + + + + + + Delete + - - - Link Details - -
-
- Original URL -

{url.original_url}

-
-
- Short Link -
- - {shortUrl} - - -
+ + + Delete this short link? + + This will permanently delete the shortened link ( + bittuly.com/{url.short_code}) and remove + all its click analytics. This action cannot be undone. + + + + Cancel + onDelete?.(url.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete Link + + + +
-
-
- Total Clicks -

{url.click_count}

+
+ + + + Link Details + +
+
+ + Original URL + +

+ {url.original_url} +

-
- Created on -

{new Date(url.created_at).toLocaleDateString([], { dateStyle: 'medium' })}

+
+ + Short Link + +
+ + {shortUrl} + + +
-
- {formattedExpiry && ( -
- Expiration Status -
- - {isExpired ? "Expired" : "Active"} +
+
+ + Total Clicks - - (Expires: {formattedExpiry}) +

{url.click_count}

+
+
+ + Created on +

+ {new Date(url.created_at).toLocaleDateString([], { + dateStyle: "medium", + })} +

- )} -
- - {/* Footer with Delete Action */} -
- - -
- - -
- ) -} + {formattedExpiry && ( +
+ + Expiration Status + +
+ + {isExpired ? "Expired" : "Active"} + + + (Expires: {formattedExpiry}) + +
+
+ )} + + {/* Footer with Delete Action */} +
+ + +
+ + + + ); +} From f0dd085dadbff02862a180537fbf1523cfbb27bf Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 01:28:10 +0530 Subject: [PATCH 31/32] feat: implement singleflight cache coalescing and scale nginx connections Details: - Add moka crate to url-service for L1 micro-caching - Wrap Redis and Postgres lookups in singleflight get_with to prevent Cache Stampedes / Thundering Herd under high concurrent load - Increase NGINX worker_connections and worker_rlimit_nofile to support up to 200,000 global concurrent connections --- Cargo.lock | 63 ++++++++++++ docker/nginx/nginx.conf | 7 +- services/url-service/Cargo.toml | 1 + services/url-service/src/handlers.rs | 143 +++++++++++++-------------- services/url-service/src/models.rs | 9 ++ 5 files changed, 149 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e2bae2..c052c38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,24 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1254,6 +1272,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1274,6 +1303,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1920,6 +1950,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock 3.4.2", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener 5.4.1", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "nom" version = "7.1.3" @@ -2267,6 +2317,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3240,6 +3296,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tcp-stream" version = "0.28.0" @@ -3578,6 +3640,7 @@ dependencies = [ "axum", "base62", "chrono", + "moka", "redis", "serde", "serde_json", diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index fb66667..c33317c 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -1,7 +1,10 @@ -worker_processes 1; +worker_processes auto; +worker_rlimit_nofile 200000; events { - worker_connections 1024; + worker_connections 50000; + use epoll; + multi_accept on; } http { diff --git a/services/url-service/Cargo.toml b/services/url-service/Cargo.toml index 03b86a0..b3501ad 100644 --- a/services/url-service/Cargo.toml +++ b/services/url-service/Cargo.toml @@ -18,3 +18,4 @@ axum = "0.8.9" base62 = "2.2.4" validator = { version = "0.20", features = ["derive"] } chrono = { version = "0.4.44", features = ["serde"] } +moka = { version = "0.12.15", features = ["future"] } diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 46ef1aa..6d5055d 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -100,88 +100,87 @@ pub async fn get_original_url( State(state): State, Path(short_code): Path, ) -> impl IntoResponse { - let mut redis = state.redis.clone(); - - // Cache lookup - let cached: Option = match redis.get::<_, Option>(&short_code).await { - Ok(v) => v, - Err(e) => { - // Redis unavailable — non-fatal, fall through to DB - tracing::warn!("redis get failed (falling back to db): {e}"); - None - } - }; + // Singleflight Cache Coalescing (Thundering Herd Protection) + // If 200,000 requests hit this exact line simultaneously, ONLY 1 will execute the async block. + // The other 199,999 will safely wait in memory and instantly receive the exact same result! + let result = state + .l1_cache + .get_with(short_code.clone(), async { + let mut redis = state.redis.clone(); - if let Some(original_url) = cached { - tracing::info!(short_code, "cache hit"); - - // Publish click to RabbitMQ - if let Ok(conn) = state.rabbitmq.get().await - && let Ok(channel) = conn.create_channel().await - { - let _ = channel - .basic_publish( - "", - "click_events_queue", - shared::lapin::options::BasicPublishOptions::default(), - short_code.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; - } - return Redirect::temporary(&original_url).into_response(); - } + // 1. Try Redis + let cached: Option = match redis.get::<_, Option>(&short_code).await { + Ok(v) => v, + Err(e) => { + tracing::warn!("redis get failed (falling back to db): {e}"); + None + } + }; - // ── 2. Cache miss — query DB ─────────────────────────────────────────── - tracing::info!(short_code, "cache miss"); - match url_service::get_original_url(&state.db, &short_code).await { - Ok(Some((original_url, expires_at))) => { - if let Some(exp) = expires_at - && exp < chrono::Utc::now() - { - return StatusCode::GONE.into_response(); + if let Some(original_url) = cached { + tracing::info!(short_code, "cache hit"); + // If it's in Redis, it's guaranteed valid because Redis handles TTL eviction natively. + return Some((original_url, None)); } - // Dynamic Redis TTL - let ttl: u64 = if let Some(exp) = expires_at { - let remaining = exp.signed_duration_since(chrono::Utc::now()).num_seconds(); - if remaining <= 0 { - return StatusCode::GONE.into_response(); // Just in case + // 2. Try DB + tracing::info!(short_code, "cache miss"); + match url_service::get_original_url(&state.db, &short_code).await { + Ok(Some((original_url, expires_at))) => { + // Populate Redis + let ttl: u64 = if let Some(exp) = expires_at { + let remaining = exp.signed_duration_since(chrono::Utc::now()).num_seconds(); + if remaining <= 0 { + return Some((original_url, expires_at)); // Let caller handle expiration + } + remaining.try_into().unwrap_or(60 * 60 * 24) // if timestamp is known, but expired, use default TTL = 24 hours + } else { + 60 * 60 * 24 // if timestamp is unknown, use default TTL = 24 hours + }; + + if let Err(e) = redis + .set_ex::<_, _, ()>(&short_code, &original_url, ttl) + .await + { + tracing::warn!("redis set_ex failed: {e}"); + } + + Some((original_url, expires_at)) + } + _ => None, // Not found or error + } + }) + .await; + + // Process Result + match result { + Some((original_url, expires_at)) => { + // Re-verify expiration in case it expired while sitting in the 3-second L1 microcache + if let Some(exp) = expires_at { + if exp < chrono::Utc::now() { + state.l1_cache.remove(&short_code).await; + return StatusCode::GONE.into_response(); } - remaining.try_into().unwrap_or(60 * 60 * 24) - } else { - 60 * 60 * 24 - }; - - // Populate cache with dynamic TTL, non-fatal if Redis is down - if let Err(e) = redis - .set_ex::<_, _, ()>(&short_code, &original_url, ttl) - .await - { - tracing::warn!("redis set_ex failed: {e}"); } - // Publish click to RabbitMQ - if let Ok(conn) = state.rabbitmq.get().await - && let Ok(channel) = conn.create_channel().await - { - let _ = channel - .basic_publish( - "", - "click_events_queue", - shared::lapin::options::BasicPublishOptions::default(), - short_code.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; + // Publish click event asynchronously via RabbitMQ + if let Ok(conn) = state.rabbitmq.get().await { + if let Ok(channel) = conn.create_channel().await { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; + } } + Redirect::temporary(&original_url).into_response() } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(err) => { - tracing::error!("{:?}", err); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } + None => StatusCode::NOT_FOUND.into_response(), } } diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index 6c55223..07950b8 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -1,7 +1,9 @@ use std::sync::Arc; +use std::time::Duration; use chrono::DateTime; use chrono::Utc; +use moka::future::Cache; use serde::Serialize; use shared::redis::RedisConn; use sqlx::PgPool; @@ -17,6 +19,7 @@ pub struct UrlState { pub started_at: Instant, #[allow(dead_code)] pub cors_origin: String, + pub l1_cache: Cache>)>>, } impl UrlState { @@ -26,12 +29,18 @@ impl UrlState { db: PgPool, cors_origin: String, ) -> Self { + let l1_cache = Cache::builder() + .max_capacity(1_000_000) + .time_to_live(Duration::from_secs(3)) + .build(); + Self { rabbitmq, db, redis, started_at: Instant::now(), cors_origin, + l1_cache, } } } From f14192d8eaffad76dea3491aa6573559bb6f3bcc Mon Sep 17 00:00:00 2001 From: bit-web24 Date: Tue, 16 Jun 2026 01:30:46 +0530 Subject: [PATCH 32/32] fix(url-service): resolve clippy warnings for collapsible_if and type_complexity --- services/url-service/src/handlers.rs | 34 ++++++++++++++-------------- services/url-service/src/models.rs | 4 +++- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index 6d5055d..d2ebc3e 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -156,26 +156,26 @@ pub async fn get_original_url( match result { Some((original_url, expires_at)) => { // Re-verify expiration in case it expired while sitting in the 3-second L1 microcache - if let Some(exp) = expires_at { - if exp < chrono::Utc::now() { - state.l1_cache.remove(&short_code).await; - return StatusCode::GONE.into_response(); - } + if let Some(exp) = expires_at + && exp < chrono::Utc::now() + { + state.l1_cache.remove(&short_code).await; + return StatusCode::GONE.into_response(); } // Publish click event asynchronously via RabbitMQ - if let Ok(conn) = state.rabbitmq.get().await { - if let Ok(channel) = conn.create_channel().await { - let _ = channel - .basic_publish( - "", - "click_events_queue", - shared::lapin::options::BasicPublishOptions::default(), - short_code.as_bytes(), - shared::lapin::BasicProperties::default(), - ) - .await; - } + if let Ok(conn) = state.rabbitmq.get().await + && let Ok(channel) = conn.create_channel().await + { + let _ = channel + .basic_publish( + "", + "click_events_queue", + shared::lapin::options::BasicPublishOptions::default(), + short_code.as_bytes(), + shared::lapin::BasicProperties::default(), + ) + .await; } Redirect::temporary(&original_url).into_response() diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index 07950b8..e7707a6 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -11,6 +11,8 @@ use sqlx::PgPool; use tokio::time::Instant; use uuid::Uuid; +pub type CachedUrlResult = Option<(String, Option>)>; + pub struct UrlState { pub rabbitmq: shared::deadpool_lapin::Pool, pub db: PgPool, @@ -19,7 +21,7 @@ pub struct UrlState { pub started_at: Instant, #[allow(dead_code)] pub cors_origin: String, - pub l1_cache: Cache>)>>, + pub l1_cache: Cache, } impl UrlState {