From 363a6b52aabb6f500313b9bf093fc4840915acb3 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Fri, 20 Feb 2026 13:06:55 +0530 Subject: [PATCH 01/18] [RTY-260025]: refactor(api): unify API definition for dev and api commands --- app/api/fast_api.py | 200 ++------------------------- app/main.py | 260 ++++------------------------------ app/routes.py | 330 ++++++++++++++++++++++++++++++++++++++++++++ app/utils/config.py | 4 +- app/utils/db.py | 48 ++++--- requirements.txt | 2 +- 6 files changed, 395 insertions(+), 449 deletions(-) create mode 100644 app/routes.py diff --git a/app/api/fast_api.py b/app/api/fast_api.py index d16c37a..3868f02 100644 --- a/app/api/fast_api.py +++ b/app/api/fast_api.py @@ -1,40 +1,20 @@ -import os -import re +# app/api/fast_api.py import traceback -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -from fastapi import APIRouter, FastAPI, Request -from fastapi.responses import HTMLResponse, JSONResponse -from pydantic import BaseModel, Field - -if TYPE_CHECKING: - from pymongo.errors import PyMongoError -else: - try: - from pymongo.errors import PyMongoError - except ImportError: - - class PyMongoError(Exception): - pass - +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from app import __version__ -from app.utils import db -from app.utils.cache import get_short_from_cache, set_cache_pair -from app.utils.helper import generate_code, is_valid_url, sanitize_url - -SHORT_CODE_PATTERN = re.compile(r"^[A-Za-z0-9]{6}$") -MAX_URL_LENGTH = 2048 +from app.routes import api_router app = FastAPI( title="Tiny API", version=__version__, description="Tiny URL Shortener API built with FastAPI", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", ) -api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"]) - @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): @@ -45,167 +25,5 @@ async def global_exception_handler(request: Request, exc: Exception): ) -class ShortenRequest(BaseModel): - url: str = Field(..., examples=["https://abcdkbd.com"]) - - -class ShortenResponse(BaseModel): - success: bool = True - input_url: str - short_code: str - created_on: datetime - - -class ErrorResponse(BaseModel): - success: bool = False - error: str - input_url: str - message: str - - -class VersionResponse(BaseModel): - version: str - - -# ------------------------------------------------- -# Home -# ------------------------------------------------- -@app.get("/", response_class=HTMLResponse, tags=["Home"]) -async def read_root(_: Request): - return """ - - - πŸŒ™ tiny API πŸŒ™ - - - -
-

πŸš€ tiny API

-

FastAPI backend for the Tiny URL shortener

- View API Documentation -
- - - """ - - -@api_v1.post("/shorten", response_model=ShortenResponse, status_code=201) -def shorten_url(payload: ShortenRequest): - print(" SHORTEN ENDPOINT HIT ", payload.url) - raw_url = payload.url.strip() - - if len(raw_url) > MAX_URL_LENGTH: - return JSONResponse( - status_code=413, content={"success": False, "input_url": payload.url} - ) - - original_url = sanitize_url(raw_url) - - if not is_valid_url(original_url): - return JSONResponse( - status_code=400, - content={ - "success": False, - "error": "INVALID_URL", - "input_url": payload.url, - "message": "Invalid URL", - }, - ) - - if db.collection is None: - cached_short = get_short_from_cache(original_url) - short_code = cached_short or generate_code() - set_cache_pair(short_code, original_url) - return { - "success": True, - "input_url": original_url, - "short_code": short_code, - "created_on": datetime.now(timezone.utc), - } - - try: - existing = db.collection.find_one({"original_url": original_url}) - except PyMongoError: - existing = None - - if existing: - return { - "success": True, - "input_url": original_url, - "short_code": existing["short_code"], - "created_on": existing["created_at"], - } - - short_code = generate_code() - try: - db.collection.insert_one( - { - "short_code": short_code, - "original_url": original_url, - "created_at": datetime.now(timezone.utc), - } - ) - except PyMongoError: - pass - - return { - "success": True, - "input_url": original_url, - "short_code": short_code, - "created_on": datetime.now(timezone.utc), - } - - -@app.get("/version") -def api_version(): - return {"version": __version__} - - -@api_v1.get("/help") -def get_help(): - return {"message": "Welcome to Tiny API. Visit /docs for API documentation."} - - -app.include_router(api_v1) +# βœ… Single source of truth for API routes only +app.include_router(api_router) diff --git a/app/main.py b/app/main.py index 8f393ba..5d65a4f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,32 +1,17 @@ +# app/main.py from contextlib import asynccontextmanager from pathlib import Path -from typing import Optional import logging +import traceback -from fastapi import FastAPI, Form, Request, status -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, JSONResponse +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -from app.api.fast_api import app as api_app +from app.routes import ui_router, api_router from app.utils import db -from app.utils.cache import ( - get_from_cache, - get_recent_from_cache, - get_short_from_cache, - rev_cache, - set_cache_pair, - url_cache, -) -from app.utils.config import DOMAIN, MAX_RECENT_URLS, SESSION_SECRET -from app.utils.helper import ( - format_date, - generate_code, - is_valid_url, - sanitize_url, -) -from app.utils.qr import generate_qr_with_logo +from app.utils.config import SESSION_SECRET # ----------------------------- @@ -39,20 +24,19 @@ async def lifespan(app: FastAPI): db.connect_db() db.start_health_check() logger.info("Application startup complete") - + yield - + logger.info("Application shutdown: Cleaning up...") await db.stop_health_check() - - # Close MongoDB client gracefully + try: if db.client is not None: db.client.close() logger.info("MongoDB client closed") except Exception as e: logger.error(f"Error closing MongoDB client: {str(e)}") - + logger.info("Application shutdown complete") @@ -63,218 +47,22 @@ async def lifespan(app: FastAPI): STATIC_DIR = BASE_DIR / "static" app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") -templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) - - -def build_short_url(short_code: str, request_host_url: str) -> str: - base_url = DOMAIN.rstrip("/") - return f"{base_url}/{short_code}" - - -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - session = request.session - - new_short_url = session.pop("new_short_url", None) - qr_enabled = session.pop("qr_enabled", False) - qr_type = session.pop("qr_type", "short") - original_url = session.pop("original_url", None) - short_code = session.pop("short_code", None) - info_message = session.pop("info_message", None) - error = session.pop("error", None) - - qr_image = None - qr_data = None - - if qr_enabled and new_short_url and short_code: - qr_data = new_short_url if qr_type == "short" else original_url - qr_filename = f"{short_code}.png" - qr_dir = STATIC_DIR / "qr" - qr_dir.mkdir(parents=True, exist_ok=True) - generate_qr_with_logo(qr_data, str(qr_dir / qr_filename)) - qr_image = f"/static/qr/{qr_filename}" - - all_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( - MAX_RECENT_URLS - ) - - return templates.TemplateResponse( - "index.html", - { - "request": request, - "urls": all_urls, - "new_short_url": new_short_url, - "qr_image": qr_image, - "qr_data": qr_data, - "qr_enabled": qr_enabled, - "original_url": original_url, - "error": error, - "info_message": info_message, - "db_available": db.get_collection() is not None, - }, - ) - - -@app.post("/shorten", response_class=RedirectResponse) -async def create_short_url( - request: Request, - original_url: str = Form(""), - generate_qr: Optional[str] = Form(None), - qr_type: str = Form("short"), -) -> RedirectResponse: - logger = logging.getLogger(__name__) - - session = request.session - qr_enabled = bool(generate_qr) - original_url = sanitize_url(original_url) - - # Basic validation (FastAPI can also handle this via Pydantic) - if not original_url or not is_valid_url(original_url): - session["error"] = "Please enter a valid URL." - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - # 1. Try Cache First - short_code: Optional[str] = get_short_from_cache(original_url) - - if not short_code: - # 2. Try Database if connected - if db.is_connected(): - existing = db.find_by_original_url(original_url) - db_code = existing.get("short_code") if existing else None - if isinstance(db_code, str): - short_code = db_code - set_cache_pair(short_code, original_url) - - # 3. Generate New if still None - if not short_code: - short_code = generate_code() - set_cache_pair(short_code, original_url) - - # Only write to database if connected - if db.is_connected(): - db.insert_url(short_code, original_url) - else: - logger.warning(f"Database not connected, URL {short_code} created in cache only") - session["info_message"] = "URL created (database temporarily unavailable)" - - # --- TYPE GUARD FOR MYPY --- - if not isinstance(short_code, str): - session["error"] = "Internal server error: Code generation failed." - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - # Mypy now knows short_code is strictly 'str' - new_short_url = build_short_url(short_code, DOMAIN) - session.update( - { - "new_short_url": new_short_url, - "qr_enabled": qr_enabled, - "qr_type": qr_type, - "original_url": original_url, - "short_code": short_code, - } - ) - - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - -@app.get("/recent", response_class=HTMLResponse) -async def recent_urls(request: Request): - recent_urls_list = db.get_recent_urls( - MAX_RECENT_URLS - ) or get_recent_from_cache(MAX_RECENT_URLS) - - normalized = [] - for item in recent_urls_list: - normalized.append( - { - "short_code": item.get("short_code"), - "original_url": item.get("original_url"), - "created_at": item.get("created_at"), - "visit_count": item.get("visit_count", 0), - } - ) - - return templates.TemplateResponse( - "recent.html", - { - "request": request, - "urls": normalized, - "format_date": format_date, - }, +# ----------------------------- +# Global error handler +# ----------------------------- +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + traceback.print_exc() + return JSONResponse( + status_code=500, + content={"success": False, "error": "INTERNAL_SERVER_ERROR"}, ) -@app.post("/delete/{short_code}") -async def delete_url(request: Request, short_code: str): - db.delete_by_short_code(short_code) - - cached = url_cache.pop(short_code, None) - if cached: - rev_cache.pop(cached.get("url"), None) - - return PlainTextResponse("", status_code=204) - - -@app.get("/{short_code}") -async def redirect_short(request: Request, short_code: str): - logger = logging.getLogger(__name__) - # Try cache first - cached_url = get_from_cache(short_code) - if cached_url: - return RedirectResponse(cached_url) - - # Check if database is connected - if not db.is_connected(): - logger.warning(f"Database not connected, cannot redirect {short_code}") - return PlainTextResponse( - "Service temporarily unavailable. Please try again later.", - status_code=503, - headers={"Retry-After": "30"} - ) - - # Try database - doc = db.increment_visit(short_code) - if doc: - set_cache_pair(short_code, doc["original_url"]) - return RedirectResponse(doc["original_url"]) - - return PlainTextResponse("Invalid or expired short URL", status_code=404) - - -@app.get("/coming-soon", response_class=HTMLResponse) -async def coming_soon(request: Request): - return templates.TemplateResponse("coming-soon.html", {"request": request}) - - -@app.get("/health") -async def health_check(): - """Health check endpoint showing database and cache status.""" - state = db.get_connection_state() - - response_data = { - "database": state, - "cache": { - "enabled": True, - "size": len(url_cache), - } - } - - status_code = 200 if state["connected"] else 503 - return JSONResponse(content=response_data, status_code=status_code) - - -app.mount("/api", api_app) - - -@app.get("/_debug/cache") -async def debug_cache(): - return { - "url_cache": url_cache, - "rev_cache": rev_cache, - "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), - "size": { - "url_cache": len(url_cache), - "rev_cache": len(rev_cache), - }, - } +# ----------------------------- +# Routers (UI + API) +# ----------------------------- +app.include_router(ui_router) # UI routes at "/" +app.include_router(api_router) # API routes at "/api" (or /api/v1 prefix inside router) diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..8807a46 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,330 @@ +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Form, Request, status +from fastapi.responses import ( + HTMLResponse, + PlainTextResponse, + RedirectResponse, + JSONResponse, +) +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field + +from app import __version__ +from app.utils import db +from app.utils.cache import ( + get_from_cache, + get_recent_from_cache, + get_short_from_cache, + set_cache_pair, + url_cache, + rev_cache, +) +from app.utils.config import DOMAIN, MAX_RECENT_URLS, MODE +from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date +from app.utils.qr import generate_qr_with_logo + +BASE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +# Routers +ui_router = APIRouter() +api_router = APIRouter() +api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"]) + + +# ---------------- UI ROUTES ---------------- + + +@ui_router.get("/", response_class=HTMLResponse) +async def index(request: Request): + session = request.session + + new_short_url = session.pop("new_short_url", None) + qr_enabled = session.pop("qr_enabled", False) + qr_type = session.pop("qr_type", "short") + original_url = session.pop("original_url", None) + short_code = session.pop("short_code", None) + info_message = session.pop("info_message", None) + error = session.pop("error", None) + + qr_image = None + qr_data = None + + if qr_enabled and new_short_url and short_code: + qr_data = new_short_url if qr_type == "short" else original_url + qr_filename = f"{short_code}.png" + qr_dir = BASE_DIR / "static" / "qr" + qr_dir.mkdir(parents=True, exist_ok=True) + generate_qr_with_logo(qr_data, str(qr_dir / qr_filename)) + qr_image = f"/static/qr/{qr_filename}" + + recent_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( + MAX_RECENT_URLS + ) + + return templates.TemplateResponse( + "index.html", + { + "request": request, + "urls": recent_urls, + "new_short_url": new_short_url, + "qr_image": qr_image, + "qr_data": qr_data, + "qr_enabled": qr_enabled, + "original_url": original_url, + "error": error, + "info_message": info_message, + "db_available": db.get_collection() is not None, + }, + ) + + +@ui_router.post("/shorten", response_class=RedirectResponse) +async def create_short_url( + request: Request, + original_url: str = Form(""), + generate_qr: Optional[str] = Form(None), + qr_type: str = Form("short"), +): + session = request.session + original_url = sanitize_url(original_url) + + if not original_url or not is_valid_url(original_url): + session["error"] = "Please enter a valid URL." + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + short_code: Optional[str] = get_short_from_cache(original_url) + + if not short_code and db.is_connected(): + existing = db.find_by_original_url(original_url) + db_code = (existing.get("short_code") if existing else None) or ( + existing.get("code") if existing else None + ) + if isinstance(db_code, str): + short_code = db_code + set_cache_pair(short_code, original_url) + + if not short_code: + short_code = generate_code() + set_cache_pair(short_code, original_url) + if db.is_connected(): + db.insert_url(short_code, original_url) + + session.update( + { + "new_short_url": f"{DOMAIN.rstrip('/')}/{short_code}", + "short_code": short_code, + "qr_enabled": bool(generate_qr), + "qr_type": qr_type, + "original_url": original_url, + } + ) + + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + +@ui_router.get("/recent", response_class=HTMLResponse) +async def recent_urls(request: Request): + recent_urls_list = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( + MAX_RECENT_URLS + ) + + return templates.TemplateResponse( + "recent.html", + {"request": request, "urls": recent_urls_list, "format_date": format_date}, + ) + + +@ui_router.get("/{short_code}") +def redirect_short_ui(short_code: str): + cached_url = get_from_cache(short_code) + if cached_url: + return RedirectResponse(cached_url) + + if db.is_connected(): + doc = db.increment_visit(short_code) + if doc and doc.get("original_url"): + set_cache_pair(short_code, doc["original_url"]) + return RedirectResponse(doc["original_url"]) + + recent_db = db.get_recent_urls(MAX_RECENT_URLS) + for item in recent_db or []: + code = item.get("short_code") or item.get("code") + if code == short_code: + original_url = item.get("original_url") + if original_url: + set_cache_pair(short_code, original_url) + return RedirectResponse(original_url) + + recent_cache = get_recent_from_cache(MAX_RECENT_URLS) + for item in recent_cache or []: + code = item.get("short_code") or item.get("code") + if code == short_code: + original_url = item.get("original_url") + if original_url: + set_cache_pair(short_code, original_url) + return RedirectResponse(original_url) + + return PlainTextResponse("Invalid short URL", status_code=404) + + +@ui_router.get("/debug/cache", include_in_schema=False) +def ui_debug_cache(): + if MODE != "local": + return PlainTextResponse("Not Found", status_code=404) + + return { + "url_cache": url_cache, + "rev_cache": rev_cache, + "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), + "size": { + "url_cache": len(url_cache), + "rev_cache": len(rev_cache), + }, + } + + +# ---------------- API ROUTES ---------------- + + +@api_router.get("/", response_class=HTMLResponse, tags=["Home"]) +async def read_root(_: Request): + return """ + + + πŸŒ™ tiny API πŸŒ™ + + + +
+

πŸš€ tiny API

+

FastAPI backend for the Tiny URL shortener

+ View API Documentation +
+ + + """ + + +@api_router.get("/version") +def api_version(): + return {"version": __version__} + + +class ShortenRequest(BaseModel): + url: str = Field(..., examples=["https://abcdkbd.com"]) + + +@api_v1.post("/shorten") +def shorten_api(payload: ShortenRequest): + original_url = sanitize_url(payload.url) + if not is_valid_url(original_url): + return JSONResponse(status_code=400, content={"error": "INVALID_URL"}) + + short_code = get_short_from_cache(original_url) + if not short_code: + short_code = generate_code() + set_cache_pair(short_code, original_url) + if db.is_connected(): + db.insert_url(short_code, original_url) + + return { + "success": True, + "input_url": original_url, + "short_code": short_code, + "created_on": datetime.now(timezone.utc), + } + + +@api_router.get("/health") +def health(): + return { + "db": db.get_connection_state(), + "cache_size": len(url_cache), + } + + +@api_router.get("/_debug/cache", include_in_schema=False) +def debug_cache(): + return { + "url_cache": url_cache, + "rev_cache": rev_cache, + "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), + "size": { + "url_cache": len(url_cache), + "rev_cache": len(rev_cache), + }, + } + + +@api_router.get("/{short_code}") +def redirect_short_api(short_code: str): + cached_url = get_from_cache(short_code) + if cached_url: + return RedirectResponse(cached_url) + + if db.is_connected(): + doc = db.increment_visit(short_code) + if doc and doc.get("original_url"): + set_cache_pair(short_code, doc["original_url"]) + return RedirectResponse(doc["original_url"]) + + recent = get_recent_from_cache(MAX_RECENT_URLS) + for item in recent or []: + code = item.get("short_code") or item.get("code") + if code == short_code: + original_url = item.get("original_url") + if original_url: + set_cache_pair(short_code, original_url) + return RedirectResponse(original_url) + + return PlainTextResponse("Invalid short URL", status_code=404) + + +api_router.include_router(api_v1) diff --git a/app/utils/config.py b/app/utils/config.py index af78124..aab3f14 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,14 +1,14 @@ - import os # ------------------------- # Helpers # ------------------------- -from app.utils.config_env import load_env # noqa: F401 +from app.utils.config_env import load_env # noqa: F401 load_env() + def _get_int(key: str, default: int) -> int: try: return int(os.getenv(key, default)) diff --git a/app/utils/db.py b/app/utils/db.py index be7ed0c..4d61ab3 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -34,14 +34,20 @@ def connect_db(max_retries: Optional[int] = None) -> bool: """ Connect to MongoDB with retry logic and exponential backoff. - + Args: max_retries: Maximum number of retry attempts (defaults to config value) - + Returns: True if connection successful, False otherwise """ - global client, db, collection, connection_state, last_connection_attempt, connection_error + global \ + client, \ + db, \ + collection, \ + connection_state, \ + last_connection_attempt, \ + connection_error if not MONGO_INSTALLED: logger.error("PyMongo is not installed") @@ -68,8 +74,10 @@ def connect_db(max_retries: Optional[int] = None) -> bool: for attempt in range(1, max_retries + 1): connection_state = "CONNECTING" last_connection_attempt = datetime.utcnow() - - logger.info(f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})...") + + logger.info( + f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})..." + ) try: # Create MongoClient with timeout and pool settings @@ -80,17 +88,17 @@ def connect_db(max_retries: Optional[int] = None) -> bool: minPoolSize=MONGO_MIN_POOL_SIZE, maxPoolSize=MONGO_MAX_POOL_SIZE, ) - + # Validate connection with ping new_client.admin.command("ping") - + # Connection successful client = new_client db = new_client[MONGO_DB_NAME] collection = db[MONGO_COLLECTION] connection_state = "CONNECTED" connection_error = None - + logger.info("Successfully connected to MongoDB") return True @@ -98,7 +106,7 @@ def connect_db(max_retries: Optional[int] = None) -> bool: error_msg = f"Connection attempt {attempt} failed: {str(e)}" logger.warning(error_msg) connection_error = str(e) - + if attempt < max_retries: logger.info(f"Retrying in {retry_delay:.1f} seconds...") time.sleep(retry_delay) @@ -120,7 +128,9 @@ def get_connection_state() -> dict[str, Any]: """Return current connection state information.""" return { "state": connection_state, - "last_attempt": last_connection_attempt.isoformat() if last_connection_attempt else None, + "last_attempt": last_connection_attempt.isoformat() + if last_connection_attempt + else None, "error": connection_error, "connected": is_connected(), } @@ -227,23 +237,23 @@ def increment_visit(short_code: str) -> Optional[dict]: async def health_check_loop() -> None: """Background task that periodically checks database connection health.""" global connection_state, connection_error - + from app.utils.config import HEALTH_CHECK_INTERVAL_SECONDS - + logger.info("Health check loop started") - + try: while True: await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS) - + logger.debug("Running health check...") - + # If disconnected, try to reconnect if not is_connected(): logger.info("Database disconnected, attempting reconnection...") connect_db() continue - + # Validate active connection with ping try: if client is not None: @@ -253,7 +263,7 @@ async def health_check_loop() -> None: logger.error(f"Health check failed: {str(e)}") connection_state = "FAILED" connection_error = str(e) - + except asyncio.CancelledError: logger.info("Health check loop cancelled") raise @@ -262,7 +272,7 @@ async def health_check_loop() -> None: def start_health_check() -> Any: """Start the background health check task.""" global health_check_task - + health_check_task = asyncio.create_task(health_check_loop()) logger.info("Health check task started") return health_check_task @@ -271,7 +281,7 @@ def start_health_check() -> Any: async def stop_health_check() -> None: """Stop the background health check task.""" global health_check_task - + if health_check_task is not None: logger.info("Stopping health check task...") health_check_task.cancel() diff --git a/requirements.txt b/requirements.txt index bf81eea..25863a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pymongo==4.16.0 ; python_version >= "3.10" and python_version < "3.13" python-dotenv==1.2.1 ; python_version >= "3.10" and python_version < "3.13" python-multipart==0.0.22 ; python_version >= "3.10" and python_version < "3.13" qrcode==8.2 ; python_version >= "3.10" and python_version < "3.13" -redis==7.1.1 ; python_version >= "3.10" and python_version < "3.13" +redis==7.2.0 ; python_version >= "3.10" and python_version < "3.13" starlette==0.52.1 ; python_version >= "3.10" and python_version < "3.13" typing-extensions==4.15.0 ; python_version >= "3.10" and python_version < "3.13" typing-inspection==0.4.2 ; python_version >= "3.10" and python_version < "3.13" From 78eb5c16608ee52d9d2e63be035fc5aa9bc77f6b Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Fri, 20 Feb 2026 13:17:14 +0530 Subject: [PATCH 02/18] [RTY-260025]: docs: add API and UI endpoints documentation to README --- README.md | 21 +++++++++++++++++++++ app/api/fast_api.py | 1 - 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff01fba..6c65895 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,27 @@ pip install dist/*.whl pip install --upgrade dist/*.whl ``` +`πŸ“‘ Endpoints` + +πŸ–₯️ UI Endpoints + +| Method | Path | Description | +| ------ | --------------- | ------------------------------------ | +| GET | `/` | Home page (URL shortener UI) | +| GET | `/recent` | Shows recently shortened URLs | +| GET | `/{short_code}` | Redirects to the original URL | +| GET | `/debug/cache` | πŸ”§ Debug cache view (local/dev only) | + +πŸ”Œ API Endpoints (v1) + +| Method | Path | Description | +| ------ | ------------------- | -------------------------------- | +| POST | `/api/v1/shorten` | Create a short URL | +| GET | `/api/v1/version` | Get API version | +| GET | `/api/v1/health` | Health check (DB + cache status) | +| GET | `/api/_debug/cache` | πŸ”§ Debug cache view (dev only) | +| GET | `/api/{short_code}` | Redirect to original URL | + ## License πŸ“œDocs diff --git a/app/api/fast_api.py b/app/api/fast_api.py index 3868f02..99ffd9d 100644 --- a/app/api/fast_api.py +++ b/app/api/fast_api.py @@ -1,4 +1,3 @@ -# app/api/fast_api.py import traceback from fastapi import FastAPI, Request from fastapi.responses import JSONResponse From df20ecd4bb31098977d58c747a20786260033271 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Fri, 20 Feb 2026 18:54:15 +0530 Subject: [PATCH 03/18] [RTY-260025]: refactor(db): enhance MongoDB connection handling and logging; add NO-DB mode support docs: update PowerShell script for bulk URL processing with error handling --- app/main.py | 23 ++++-- app/routes.py | 39 +++++++--- app/utils/db.py | 166 ++++++++++++++++-------------------------- docs/run_with_curl.md | 37 ++++++++-- 4 files changed, 137 insertions(+), 128 deletions(-) diff --git a/app/main.py b/app/main.py index 5d65a4f..bd7e85f 100644 --- a/app/main.py +++ b/app/main.py @@ -15,20 +15,29 @@ # ----------------------------- -# Lifespan: env + DB connect ONCE +# Lifespan: env + DB connect ONCE (DB-optional) # ----------------------------- @asynccontextmanager async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) - logger.info("Application startup: Connecting to database...") - db.connect_db() - db.start_health_check() - logger.info("Application startup complete") + logger.info("Application startup: Initializing services...") + + db_ok = db.connect_db() + if db_ok: + db.start_health_check() + logger.info("🟒 MongoDB enabled") + else: + logger.warning("🟑 MongoDB disabled (cache-only mode)") + logger.info("Application startup complete") yield logger.info("Application shutdown: Cleaning up...") - await db.stop_health_check() + + try: + await db.stop_health_check() + except Exception as e: + logger.error(f"Error stopping health check: {str(e)}") try: if db.client is not None: @@ -65,4 +74,4 @@ async def global_exception_handler(request: Request, exc: Exception): # Routers (UI + API) # ----------------------------- app.include_router(ui_router) # UI routes at "/" -app.include_router(api_router) # API routes at "/api" (or /api/v1 prefix inside router) +app.include_router(api_router) # API routes at "/api" diff --git a/app/routes.py b/app/routes.py index 8807a46..c0e29ac 100644 --- a/app/routes.py +++ b/app/routes.py @@ -23,7 +23,7 @@ url_cache, rev_cache, ) -from app.utils.config import DOMAIN, MAX_RECENT_URLS, MODE +from app.utils.config import DOMAIN, MAX_RECENT_URLS from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date from app.utils.qr import generate_qr_with_logo @@ -172,19 +172,34 @@ def redirect_short_ui(short_code: str): return PlainTextResponse("Invalid short URL", status_code=404) -@ui_router.get("/debug/cache", include_in_schema=False) -def ui_debug_cache(): - if MODE != "local": - return PlainTextResponse("Not Found", status_code=404) +@ui_router.delete("/recent/{short_code}") +def delete_recent_api(short_code: str): + """ + Delete a short URL from recent list (cache-first, DB optional). + UI should never fail if DB is down. + """ + + # 1️⃣ Remove from cache (source of truth for UI) + recent = get_recent_from_cache(MAX_RECENT_URLS) + removed_from_cache = False + + for i, item in enumerate(recent or []): + code = item.get("short_code") or item.get("code") + if code == short_code: + recent.pop(i) + removed_from_cache = True + break + + # 2️⃣ Best-effort DB delete + db_deleted = False + if db.is_connected(): + db_deleted = db.delete_by_short_code(short_code) + # 3️⃣ Always succeed for UI return { - "url_cache": url_cache, - "rev_cache": rev_cache, - "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), - "size": { - "url_cache": len(url_cache), - "rev_cache": len(rev_cache), - }, + "success": True, + "removed_from_cache": removed_from_cache, + "db_deleted": bool(db_deleted), } diff --git a/app/utils/db.py b/app/utils/db.py index 4d61ab3..e6f3610 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -10,7 +10,6 @@ MONGO_INSTALLED = True except ImportError: MongoClient: Any = None # type: ignore - Collection: Any # type: ignore PyMongoError = Exception # type: ignore MONGO_INSTALLED = False @@ -31,7 +30,7 @@ health_check_task: Any = None -def connect_db(max_retries: Optional[int] = None) -> bool: +def connect_db(max_retries: int = 1) -> bool: """ Connect to MongoDB with retry logic and exponential backoff. @@ -55,75 +54,58 @@ def connect_db(max_retries: Optional[int] = None) -> bool: connection_error = "PyMongo not installed" return False + if not MONGO_URI: + logger.warning("⚠️ MONGO_URI not set. Running in NO-DB mode.") + connection_state = "FAILED" + connection_error = "MONGO_URI missing" + return False + from app.utils.config import ( - MONGO_MAX_RETRIES, - MONGO_INITIAL_RETRY_DELAY, - MONGO_MAX_RETRY_DELAY, MONGO_TIMEOUT_MS, MONGO_SOCKET_TIMEOUT_MS, MONGO_MIN_POOL_SIZE, MONGO_MAX_POOL_SIZE, ) - import time - if max_retries is None: - max_retries = MONGO_MAX_RETRIES + connection_state = "CONNECTING" + last_connection_attempt = datetime.utcnow() - retry_delay = MONGO_INITIAL_RETRY_DELAY + try: + new_client: Any = MongoClient( + MONGO_URI, + serverSelectionTimeoutMS=MONGO_TIMEOUT_MS, + socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS, + minPoolSize=MONGO_MIN_POOL_SIZE, + maxPoolSize=MONGO_MAX_POOL_SIZE, + ) - for attempt in range(1, max_retries + 1): - connection_state = "CONNECTING" - last_connection_attempt = datetime.utcnow() + new_client.admin.command("ping") - logger.info( - f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})..." - ) + client = new_client + db = new_client[MONGO_DB_NAME] + collection = db[MONGO_COLLECTION] - try: - # Create MongoClient with timeout and pool settings - new_client: Any = MongoClient( - MONGO_URI, - serverSelectionTimeoutMS=MONGO_TIMEOUT_MS, - socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS, - minPoolSize=MONGO_MIN_POOL_SIZE, - maxPoolSize=MONGO_MAX_POOL_SIZE, - ) - - # Validate connection with ping - new_client.admin.command("ping") - - # Connection successful - client = new_client - db = new_client[MONGO_DB_NAME] - collection = db[MONGO_COLLECTION] - connection_state = "CONNECTED" - connection_error = None - - logger.info("Successfully connected to MongoDB") - return True - - except Exception as e: - error_msg = f"Connection attempt {attempt} failed: {str(e)}" - logger.warning(error_msg) - connection_error = str(e) - - if attempt < max_retries: - logger.info(f"Retrying in {retry_delay:.1f} seconds...") - time.sleep(retry_delay) - # Exponential backoff: double delay, cap at max - retry_delay = min(retry_delay * 2, MONGO_MAX_RETRY_DELAY) - else: - logger.error(f"Failed to connect after {max_retries} attempts") - connection_state = "FAILED" - client = db = collection = None - - return False - - -def get_collection() -> Optional[dict[str, Any]]: + connection_state = "CONNECTED" + connection_error = None + logger.info("βœ… MongoDB connected") + return True + + except Exception as e: + logger.warning(f"⚠️ MongoDB not reachable. Running in NO-DB mode: {e}") + connection_state = "FAILED" + connection_error = str(e) + client = db = collection = None + return False + + +def get_collection() -> Optional[Any]: return collection +def is_connected() -> bool: + return connection_state == "CONNECTED" and collection is not None + + def get_connection_state() -> dict[str, Any]: """Return current connection state information.""" return { @@ -136,13 +118,8 @@ def get_connection_state() -> dict[str, Any]: } -def is_connected() -> bool: - """Check if database is currently connected.""" - return connection_state == "CONNECTED" and collection is not None - - # ------------------------ -# DB Operations +# DB Operations (NO-OP SAFE) # ------------------------ @@ -153,61 +130,50 @@ def find_by_original_url(original_url: str) -> Optional[dict]: try: return collection.find_one({"original_url": original_url}) except PyMongoError as e: - logger.error(f"Error finding URL: {str(e)}") - global connection_state, connection_error - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"DB error (find_by_original_url): {e}") + _mark_failed(e) return None def insert_url(short_code: str, original_url: str) -> bool: if not is_connected(): - logger.warning("Database not connected, cannot insert URL") return False try: collection.insert_one( { "short_code": short_code, "original_url": original_url, - "created_at": __import__("datetime").datetime.utcnow(), + "created_at": datetime.utcnow(), "visit_count": 0, } ) return True except PyMongoError as e: - logger.error(f"Error inserting URL: {str(e)}") - global connection_state, connection_error - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"DB error (insert_url): {e}") + _mark_failed(e) return False def delete_by_short_code(short_code: str) -> bool: if not is_connected(): - logger.warning("Database not connected, cannot delete URL") return False try: collection.delete_one({"short_code": short_code}) return True except PyMongoError as e: - logger.error(f"Error deleting URL: {str(e)}") - global connection_state, connection_error - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"DB error (delete_by_short_code): {e}") + _mark_failed(e) return False def get_recent_urls(limit: int = 10) -> list[dict]: if not is_connected(): - logger.warning("Database not connected, cannot get recent URLs") return [] try: return list(collection.find().sort("created_at", -1).limit(limit)) except PyMongoError as e: - logger.error(f"Error getting recent URLs: {str(e)}") - global connection_state, connection_error - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"DB error (get_recent_urls): {e}") + _mark_failed(e) return [] @@ -222,47 +188,43 @@ def increment_visit(short_code: str) -> Optional[dict]: return_document=True, ) except PyMongoError as e: - logger.error(f"Error incrementing visit: {str(e)}") - global connection_state, connection_error - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"DB error (increment_visit): {e}") + _mark_failed(e) return None +def _mark_failed(e: Exception) -> None: + global connection_state, connection_error, client, db, collection + connection_state = "FAILED" + connection_error = str(e) + client = db = collection = None + + # ------------------------ -# Health Check +# Health Check (Background reconnect) # ------------------------ async def health_check_loop() -> None: - """Background task that periodically checks database connection health.""" - global connection_state, connection_error - from app.utils.config import HEALTH_CHECK_INTERVAL_SECONDS - logger.info("Health check loop started") + logger.info("πŸ«€ DB health check started") try: while True: await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS) - logger.debug("Running health check...") - - # If disconnected, try to reconnect if not is_connected(): - logger.info("Database disconnected, attempting reconnection...") + logger.info("πŸ” DB disconnected. Retrying connection...") connect_db() continue - # Validate active connection with ping try: - if client is not None: + if client: client.admin.command("ping") - logger.debug("Health check passed") except Exception as e: - logger.error(f"Health check failed: {str(e)}") - connection_state = "FAILED" - connection_error = str(e) + logger.error(f"❌ Health check failed: {e}") + _mark_failed(e) except asyncio.CancelledError: logger.info("Health check loop cancelled") diff --git a/docs/run_with_curl.md b/docs/run_with_curl.md index e7d6a6f..0202b9b 100644 --- a/docs/run_with_curl.md +++ b/docs/run_with_curl.md @@ -55,14 +55,37 @@ in request folder Create a file named input.json in the project root: ```# $data = Get-Content .\request\urls.json -Raw | ConvertFrom-Json -foreach ($item in $data) { - $body = @{ url = $item.url } | ConvertTo-Json +Write-Host "πŸš€ Processing URLs..." - Invoke-RestMethod ` - -Uri "http://127.0.0.1:8001/api/v1/shorten" ` - -Method POST ` - -ContentType "application/json" ` - -Body $body +foreach ($item in $data) { + if (-not $item.url) { + Write-Host "❌ Skipping invalid entry (missing url field)" + continue + } + + $body = @{ url = $item.url } | ConvertTo-Json -Depth 3 + + try { + $response = Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/shorten" ` + -Method POST ` + -ContentType "application/json" ` + -Body $body + + Write-Host "βœ… SUCCESS: $($item.url) -> $($response.short_code)" + } + catch { + $status = $_.Exception.Response.StatusCode.value__ 2>$null + if ($status -eq 400) { + Write-Host "❌ ERROR: $($item.url) - Invalid URL" + } + elseif ($status -eq 404) { + Write-Host "❌ ERROR: $($item.url) - API endpoint not found" + } + else { + Write-Host "❌ ERROR: $($item.url) - Rejected by API" + } + } } From 5e3f57df097183816d3c59f7448074dee3ebc4a1 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Fri, 20 Feb 2026 18:55:11 +0530 Subject: [PATCH 04/18] [RTY-260025]: --- app/utils/config.py | 1 + app/utils/db.py | 14 ++++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index aab3f14..7bd3cec 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,4 +1,5 @@ import os + # ------------------------- # Helpers # ------------------------- diff --git a/app/utils/db.py b/app/utils/db.py index e6f3610..6d45537 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -40,13 +40,7 @@ def connect_db(max_retries: int = 1) -> bool: Returns: True if connection successful, False otherwise """ - global \ - client, \ - db, \ - collection, \ - connection_state, \ - last_connection_attempt, \ - connection_error + global client, db, collection, connection_state, last_connection_attempt, connection_error if not MONGO_INSTALLED: logger.error("PyMongo is not installed") @@ -110,9 +104,9 @@ def get_connection_state() -> dict[str, Any]: """Return current connection state information.""" return { "state": connection_state, - "last_attempt": last_connection_attempt.isoformat() - if last_connection_attempt - else None, + "last_attempt": ( + last_connection_attempt.isoformat() if last_connection_attempt else None + ), "error": connection_error, "connected": is_connected(), } From 714d64e6c2ee4719088d1f7194e3684e09cf2e17 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Sat, 21 Feb 2026 16:19:50 +0530 Subject: [PATCH 05/18] [RTY-260025]: feat(cache): implement cache management endpoints and background cleanup task --- app/main.py | 34 ++++++++++++++++- app/routes.py | 36 ++++++++++-------- app/utils/cache.py | 95 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 131 insertions(+), 34 deletions(-) diff --git a/app/main.py b/app/main.py index bd7e85f..5af8621 100644 --- a/app/main.py +++ b/app/main.py @@ -3,17 +3,34 @@ from pathlib import Path import logging import traceback +import asyncio from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware -from app.routes import ui_router, api_router +from app.routes import ui_router from app.utils import db +from app.utils.cache import cleanup_expired from app.utils.config import SESSION_SECRET +# ----------------------------- +# Background cache cleanup task +# ----------------------------- +async def cache_health_check(): + logger = logging.getLogger(__name__) + logger.info("🧹 Cache cleanup task started") + + while True: + try: + cleanup_expired() + except Exception as e: + logger.error(f"Cache cleanup error: {e}") + await asyncio.sleep(5) # cleanup every 5 seconds + + # ----------------------------- # Lifespan: env + DB connect ONCE (DB-optional) # ----------------------------- @@ -22,6 +39,7 @@ async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) logger.info("Application startup: Initializing services...") + # DB init (optional) db_ok = db.connect_db() if db_ok: db.start_health_check() @@ -29,16 +47,29 @@ async def lifespan(app: FastAPI): else: logger.warning("🟑 MongoDB disabled (cache-only mode)") + # Cache TTL cleanup + cache_task = asyncio.create_task(cache_health_check()) + logger.info("🧹 Cache TTL cleanup enabled") + logger.info("Application startup complete") yield logger.info("Application shutdown: Cleaning up...") + # Stop cache task + cache_task.cancel() + try: + await cache_task + except asyncio.CancelledError: + logger.info("🧹 Cache cleanup task stopped") + + # Stop DB health check try: await db.stop_health_check() except Exception as e: logger.error(f"Error stopping health check: {str(e)}") + # Close Mongo client if exists try: if db.client is not None: db.client.close() @@ -74,4 +105,3 @@ async def global_exception_handler(request: Request, exc: Exception): # Routers (UI + API) # ----------------------------- app.include_router(ui_router) # UI routes at "/" -app.include_router(api_router) # API routes at "/api" diff --git a/app/routes.py b/app/routes.py index c0e29ac..75c218f 100644 --- a/app/routes.py +++ b/app/routes.py @@ -2,8 +2,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional - -from fastapi import APIRouter, Form, Request, status +from app.utils.cache import list_cache_clean, cleanup_expired, clear_cache +from fastapi import APIRouter, Form, Request, status, HTTPException from fastapi.responses import ( HTMLResponse, PlainTextResponse, @@ -21,7 +21,6 @@ get_short_from_cache, set_cache_pair, url_cache, - rev_cache, ) from app.utils.config import DOMAIN, MAX_RECENT_URLS from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date @@ -139,6 +138,24 @@ async def recent_urls(request: Request): ) +@ui_router.get("/cache/list") +def cache_list_ui(): + return list_cache_clean() + + +@ui_router.post("/cache/clean") +def cache_clean_ui(key: str): + if key == "CACHE_TTL": + cleanup_expired() + return {"status": "cleaned", "strategy": "TTL", **list_cache_clean()} + + if key == "ALL": + clear_cache() + return {"status": "cleared", "strategy": "FULL_RESET", **list_cache_clean()} + + raise HTTPException(400, "Invalid key. Use key=CACHE_TTL or key=ALL") + + @ui_router.get("/{short_code}") def redirect_short_ui(short_code: str): cached_url = get_from_cache(short_code) @@ -305,19 +322,6 @@ def health(): } -@api_router.get("/_debug/cache", include_in_schema=False) -def debug_cache(): - return { - "url_cache": url_cache, - "rev_cache": rev_cache, - "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), - "size": { - "url_cache": len(url_cache), - "rev_cache": len(rev_cache), - }, - } - - @api_router.get("/{short_code}") def redirect_short_api(short_code: str): cached_url = get_from_cache(short_code) diff --git a/app/utils/cache.py b/app/utils/cache.py index a6f1c6a..14027b4 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -26,6 +26,31 @@ def _now() -> float: return time.time() +# ----------------------- +# Core cache operations +# ----------------------- + + +def _delete_pair_by_short_code(short_code: str) -> None: + """ + Remove both sides of cache using short_code. + """ + data = url_cache.pop(short_code, None) + if data: + original_url = data["url"] + rev_cache.pop(original_url, None) + + +def _delete_pair_by_url(original_url: str) -> None: + """ + Remove both sides of cache using original_url. + """ + data = rev_cache.pop(original_url, None) + if data: + short_code = data["short_code"] + url_cache.pop(short_code, None) + + def get_from_cache(short_code: str) -> str | None: data = url_cache.get(short_code) @@ -33,7 +58,7 @@ def get_from_cache(short_code: str) -> str | None: return None if data["expires_at"] < _now(): - url_cache.pop(short_code, None) + _delete_pair_by_short_code(short_code) return None return data["url"] @@ -46,12 +71,11 @@ def get_short_from_cache(original_url: str) -> str | None: return None if data["expires_at"] < _now(): - rev_cache.pop(original_url, None) + _delete_pair_by_url(original_url) return None # Touch for recent tracking data["last_accessed"] = _now() - return data["short_code"] @@ -79,24 +103,31 @@ def clear_cache() -> None: rev_cache.clear() +# ----------------------- +# TTL Cleanup (ACTIVE) +# ----------------------- + + def cleanup_expired() -> None: """ - Optional: Manually remove expired cache entries. - Can be called periodically (cron/background task). + Actively remove expired cache entries from both caches. + Safe to run periodically in background task. """ now = _now() expired_short_codes = [ key for key, value in url_cache.items() if value["expires_at"] < now ] - for key in expired_short_codes: - url_cache.pop(key, None) + + for short_code in expired_short_codes: + _delete_pair_by_short_code(short_code) expired_urls = [ key for key, value in rev_cache.items() if value["expires_at"] < now ] - for key in expired_urls: - rev_cache.pop(key, None) + + for original_url in expired_urls: + _delete_pair_by_url(original_url) # ----------------------- @@ -106,22 +137,54 @@ def cleanup_expired() -> None: def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: """ - Returns recent URLs based on cache activity (no duplicates, TTL-aware). - Shape matches DB docs. + Returns recent URLs based on cache activity (TTL-aware, no duplicates). """ now = _now() - items = [ + valid_items = [ { "short_code": data["short_code"], "original_url": original_url, + "last_accessed": data["last_accessed"], } for original_url, data in rev_cache.items() if data["expires_at"] >= now ] - items.sort( - key=lambda x: rev_cache[x["original_url"]]["last_accessed"], reverse=True - ) + valid_items.sort(key=lambda x: x["last_accessed"], reverse=True) - return items[:limit] + return [ + { + "short_code": item["short_code"], + "original_url": item["original_url"], + } + for item in valid_items[:limit] + ] + + +# ----------------------- +# Debug / Introspection +# ----------------------- + + +def list_cache_clean() -> dict: + """ + Clean UI-friendly cache view (TTL-aware, no debug noise). + """ + now = _now() + + items = [ + { + "short_code": data["short_code"], + "original_url": original_url, + } + for original_url, data in rev_cache.items() + if data["expires_at"] >= now + ] + + return { + "count": len(items), + "items": items, + "MAX_RECENT_URLS": MAX_RECENT_URLS, + "CACHE_TTL": CACHE_TTL, + } From bb62e9c9667407291633c81ed0f7a9102b679463 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Sat, 21 Feb 2026 19:34:12 +0530 Subject: [PATCH 06/18] [RTY-260025]: feat(cache): add forceful cache clear endpoint and clean cache listing --- app/routes.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/routes.py b/app/routes.py index 75c218f..6cf963d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -145,15 +145,19 @@ def cache_list_ui(): @ui_router.post("/cache/clean") def cache_clean_ui(key: str): - if key == "CACHE_TTL": - cleanup_expired() - return {"status": "cleaned", "strategy": "TTL", **list_cache_clean()} - - if key == "ALL": - clear_cache() - return {"status": "cleared", "strategy": "FULL_RESET", **list_cache_clean()} + """ + key=CLEAR_ALL -> force delete everything from cache + key=FORCE_ONE -> force delete one entry (requires short_code or original_url) + """ + if key == "CLEAR_ALL": + clear_cache() # πŸ”₯ force wipe all cache + return { + "status": "cleared", + "strategy": "FORCE_FULL_RESET", + **list_cache_clean(), + } - raise HTTPException(400, "Invalid key. Use key=CACHE_TTL or key=ALL") + raise HTTPException(400, "Invalid key. Use key=CLEAR_ALL") @ui_router.get("/{short_code}") From 7bb427b51d763dd7db2d5f10acaca78a52661c51 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Sat, 21 Feb 2026 19:35:34 +0530 Subject: [PATCH 07/18] [RTY-260025]: refactor(cache): remove unused cleanup_expired import from routes --- app/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes.py b/app/routes.py index 6cf963d..a8b05be 100644 --- a/app/routes.py +++ b/app/routes.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from app.utils.cache import list_cache_clean, cleanup_expired, clear_cache +from app.utils.cache import list_cache_clean, clear_cache from fastapi import APIRouter, Form, Request, status, HTTPException from fastapi.responses import ( HTMLResponse, From 27d6ea113a848953fd843491b43a433bd52a95e6 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Sat, 21 Feb 2026 20:56:50 +0530 Subject: [PATCH 08/18] [RTY-260025]: feat(routes): include ui_router in FastAPI app and remove unused redirect_short_api function --- app/api/fast_api.py | 4 ++-- app/routes.py | 24 ------------------------ 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/app/api/fast_api.py b/app/api/fast_api.py index 99ffd9d..d8d5a99 100644 --- a/app/api/fast_api.py +++ b/app/api/fast_api.py @@ -3,7 +3,7 @@ from fastapi.responses import JSONResponse from app import __version__ -from app.routes import api_router +from app.routes import api_router, ui_router app = FastAPI( title="Tiny API", @@ -24,5 +24,5 @@ async def global_exception_handler(request: Request, exc: Exception): ) -# βœ… Single source of truth for API routes only app.include_router(api_router) +app.include_router(ui_router) diff --git a/app/routes.py b/app/routes.py index a8b05be..4fc7ea2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -326,28 +326,4 @@ def health(): } -@api_router.get("/{short_code}") -def redirect_short_api(short_code: str): - cached_url = get_from_cache(short_code) - if cached_url: - return RedirectResponse(cached_url) - - if db.is_connected(): - doc = db.increment_visit(short_code) - if doc and doc.get("original_url"): - set_cache_pair(short_code, doc["original_url"]) - return RedirectResponse(doc["original_url"]) - - recent = get_recent_from_cache(MAX_RECENT_URLS) - for item in recent or []: - code = item.get("short_code") or item.get("code") - if code == short_code: - original_url = item.get("original_url") - if original_url: - set_cache_pair(short_code, original_url) - return RedirectResponse(original_url) - - return PlainTextResponse("Invalid short URL", status_code=404) - - api_router.include_router(api_v1) From 20cfa7979382125af3d6823da883ffe1710b330a Mon Sep 17 00:00:00 2001 From: Ravindrayadav04 Date: Tue, 24 Feb 2026 21:26:15 +0530 Subject: [PATCH 09/18] fix the bug related to visit count and change the ui also --- .env.sample | 4 +- app/main.py | 32 + app/static/style.css | 1715 +++++++++++++++++++------------------ app/templates/recent.html | 17 +- app/utils/cache.py | 21 +- app/utils/config.py | 5 +- app/utils/db.py | 40 +- 7 files changed, 987 insertions(+), 847 deletions(-) diff --git a/.env.sample b/.env.sample index bc9711c..24e1e91 100644 --- a/.env.sample +++ b/.env.sample @@ -2,5 +2,5 @@ MODE=local MONGO_URI=mongodb://:@127.0.0.1:27017/?authSource=admin&retryWrites=true&w=majority DOMAIN=https://localhost:8001 PORT=8001 -API_VERSION="" -APP_NAMe="LOCAL" \ No newline at end of file +API_VERSION="/api/v1" +APP_NAME="LOCAL" \ No newline at end of file diff --git a/app/main.py b/app/main.py index 8f393ba..0779b02 100644 --- a/app/main.py +++ b/app/main.py @@ -216,12 +216,44 @@ async def delete_url(request: Request, short_code: str): return PlainTextResponse("", status_code=204) +#@app.get("/{short_code}") +#async def redirect_short(request: Request, short_code: str): +# logger = logging.getLogger(__name__) +# # Try cache first +# cached_url = get_from_cache(short_code) +# if cached_url: +# return RedirectResponse(cached_url) + +# # Check if database is connected +# if not db.is_connected(): +# logger.warning(f"Database not connected, cannot redirect {short_code}") +# return PlainTextResponse( +# "Service temporarily unavailable. Please try again later.", +# status_code=503, +# headers={"Retry-After": "30"}, +# ) + +# # Try database +# doc = db.increment_visit(short_code) +# if doc: +# set_cache_pair(short_code, doc["original_url"]) +# return RedirectResponse(doc["original_url"]) + +# return PlainTextResponse("Invalid or expired short URL", status_code=404) + @app.get("/{short_code}") async def redirect_short(request: Request, short_code: str): logger = logging.getLogger(__name__) # Try cache first cached_url = get_from_cache(short_code) if cached_url: + + if db.is_connected(): + db.increment_visit(short_code) + else: + from app.utils.cache import increment_cache_visit + increment_cache_visit(short_code) + return RedirectResponse(cached_url) # Check if database is connected diff --git a/app/static/style.css b/app/static/style.css index b754687..d32e8d9 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,815 +1,900 @@ -html, -body { - height: 100%; - margin: 0; - font-family: Arial; - padding: 0; - font-family: "Poppins", system-ui, Arial, sans-serif; - background: var(--bg); - background-size: cover; - background-position: center; - background-size: cover; - background-position: center; -} -input { - width: 70%; - margin-top: 2px; - margin-bottom: 2px; - font-size: 16px; -} -.admin-box { - margin: 120px auto 60px; - /* space from header + footer */ -} -.app-layout { - min-height: 100vh; - display: flex; - flex-direction: column; - margin-top: var(--header-height); -} -button { - padding: 8px; - margin: 5px; -} -.error-box { - margin-bottom: 15px; - padding: 10px; - color: #ff4d4d; - border-radius: 8px; - font-weight: 600; -} - -.dark-theme h1 { - background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); -} - -.dark-theme p { - background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); -} -.dark-theme { - --bg-overlay: rgba(0, 0, 0, 0.75); - --glass-bg: rgba(0, 0, 0, 0.4); - --text-color: #fff; - --input-bg: rgba(50, 50, 50, 0.8); - --input-text-color: #fff; -} - -@keyframes pop { - 0% { - transform: scale(0.7); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } -} -/* INPUT CONTAINER */ -.input-field { - flex: 1 1 700px; - display: flex; - align-items: center; - gap: 12px; - border-radius: 12px; - border: 2px solid rgb(6, 0, 0); - background: transparent; /* IMPORTANT */ - padding: 12px 12px; -} -.dark-theme .input-field { - border-color: #ffffff; -} -/* INPUT ITSELF */ -.input-field input[type="text"] { - width: 100%; - border: none; - outline: none; - background-color: transparent !important; - background-image: none !important; - box-shadow: none !important; - font-size: 23px; -} - -.input-field input { - color: #000 !important; -} - -.dark-theme .input-field input { - color: #fff !important; -} - -.input-field input:-webkit-autofill, -.input-field input:-webkit-autofill:hover, -.input-field input:-webkit-autofill:focus, -.input-field input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 1000px transparent inset !important; - box-shadow: 0 0 0 1000px transparent inset !important; - background-color: transparent !important; - background-image: none !important; - transition: background-color 9999s ease-in-out 0s; -} - -.input-field input:-webkit-autofill { - -webkit-text-fill-color: #000 !important; -} - -.dark-theme .input-field input:-webkit-autofill { - -webkit-text-fill-color: #fff !important; -} -.input-field input::selection, -.input-field input::-moz-selection { - background: transparent; - color: inherit; -} -.short-code { - color: #0a0000; /* blue like links */ - font-weight: 700; -} - -.app-header { - position: fixed; - top: 0; - left: 0; - width: 97%; - height: 55px; - background: white; - display: flex; - align-items: center; - padding: 0 28px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); - z-index: 1000; -} - -/* Dark mode */ -.dark-theme .app-header { - background: linear-gradient(180deg, #0b1220, #050b14); -} - -footer { - margin-top: 0; -} - -body.dark-theme, -body.dark-theme .page, -body.dark-theme main, -body.dark-theme section { - background: #0f1720 !important; -} - -.header-left { - display: flex; - align-items: center; - gap: 12px; -} - -.app-logo { - width: 36px; - height: 36px; - background: linear-gradient(135deg, #2563eb, #5ab9ff); - color: white; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; -} - -.app-name { - font-size: 20px; - font-weight: 700; - color: #111827; -} - -.dark-theme .app-name { - color: #f8fafc; -} - -.header-nav { - position: absolute; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 26px; -} - -.nav-link { - text-decoration: none; - color: #111827; - font-weight: 500; - position: relative; -} - -.dark-theme .nav-link { - color: #e5e7eb; -} - -.nav-link:hover { - color: #2563eb; -} - -.nav-link.active::after { - content: ""; - position: absolute; - bottom: -6px; - left: 0; - width: 100%; - height: 2px; - background: #111827; -} - -.dark-theme .nav-link.active::after { - background: #f8fafc; -} - -.header-right { - margin-left: auto; - display: flex; - align-items: center; -} - -:root { - --header-height: 55px; - --bg: #eefaf8; - --card: rgba(255, 255, 255, 0.95); - --muted: #7b8b8a; - --accent-1: #5ab9ff; - --accent-2: #4cb39f; - --accent-grad: linear-gradient(90deg, #4cb39f, #5ab9ff); - --success: #2fb06e; - --glass: rgba(255, 255, 255, 0.85); -} - -.dark-theme { - --bg-overlay: rgba(0, 0, 0, 0.75); - --glass-bg: rgba(0, 0, 0, 0.4); - --text-color: #f3f3f3; - --input-bg: rgba(11, 10, 10, 0.8); - --button-bg: linear-gradient(90deg, #4444ff, #2266ff); - --recent-bg: rgba(255, 255, 255, 0.1); -} - -/* Preserve your dark theme variables too */ -body.dark-theme { - --bg: #0f1720; - --card: rgba(10, 14, 18, 0.92); - --muted: #9aa7a6; -} - -.page { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 2rem; - min-height: 80vh; -} - -.theme-toggle { - background: transparent; - border: none; - cursor: pointer; - padding: 8px; - border-radius: 8px; - font-weight: 700; - background: var(--card); -} - -/* Hero */ -.hero { - width: 100%; - max-width: 1100px; - background: transparent; - text-align: center; - padding: 10px; -} - -.hero h1 { - margin: 10px 0 14px; - font-size: 36px; - line-height: 1.05; - color: #000606; -} - -.hero p { - margin: var(--bg-overlay); - color: var(--muted); - max-width: 820px; - margin-left: auto; - margin-right: auto; - color: #000606; -} - -/* Main card & input */ -.card { - width: 100%; - max-width: 1100px; - background: var(--card); - border-radius: 14px; - padding: 15px; - box-shadow: 0 18px 50px rgba(8, 24, 24, 0.06); -} - -.cta { - min-width: 220px; - padding: 14px 22px; - border-radius: 12px; - border: none; - color: rgb(12, 1, 1); - font-weight: 700; - cursor: pointer; - background: var(--accent-grad); - box-shadow: 0 12px 28px rgba(77, 163, 185, 0.12); -} - -.small-action { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); - margin-top: 10px; -} - -.result { - margin-top: 26px; - background: white; - border-radius: 12px; - padding: 20px; - border: 1px solid rgba(22, 60, 55, 0.03); - box-shadow: 0 8px 28px rgba(7, 20, 20, 0.03); -} - -.result-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; -} - -.result-header .dot { - width: 30px; - height: 30px; - background: var(--success); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-weight: 700; -} - -.short-actions { - display: flex; - justify-content: center; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-radius: 12px; - background: linear-gradient(180deg, rgba(75, 194, 176, 0.06), rgba(94, 207, 255, 0.04)); -} - -.short-box input { - align-items: center; - padding: 10px; - font-size: 15px; -} - -.btn-copy { - border: none; - padding: 10px 14px; - border-radius: 8px; - color: white; - font-weight: 700; - cursor: pointer; -} - -.btn-share { - background: #f2f5f5; - border: none; - padding: 10px 14px; - border-radius: 8px; - color: #0b2b2a; - font-weight: 700; - cursor: pointer; - margin-left: 6px; -} - -.meta-row { - align-items: center; - justify-content: center; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2px; - padding: 16px; - margin-top: 1px; - align-items: top; - color: black; -} -.result-body { - margin-top: 30px; - - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.qr-block { - text-align: center; - padding-top: 8px; -} - -.qr-block img { - height: 15rem; - align-items: center; - aspect-ratio: 1; - box-shadow: 0 10px 20px rgba(10, 20, 30, 0.06); - outline: 2px solid green; - outline-offset: 4px; -} - -.download-qr { - display: inline-block; - margin-top: 12px; - text-decoration: none; - color: var(--accent-1); - font-weight: 700; -} - -.action-row { - display: flex; - justify-content: right; - align-items: right; -} - -.action-secondary { - background: #f6fbfb; - border: 1px solid rgba(0, 0, 0, 0.03); - border-radius: 10px; - cursor: pointer; - font-weight: 700; -} - -/* Force Generate QR to stay on one line */ -.qr-inline { - display: inline-flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} - -.qr-inline input { - margin: 0; -} - -/* Responsive */ -@media (max-width: 880px) { - .input-row { - flex-direction: column; - } - - .cta { - width: 100%; - } - - .meta-row { - grid-template-columns: 1fr; - } -} -.result-title { - font-weight: 700; - color: #0e34f6; -} - -.dark-theme .result-title { - color: #150cff; -} - -footer { - min-height: auto; -} - -.app-footer { - background: white; - color: #e5e7eb; - padding: 8px 10px; - margin-top: auto; - position: relative; -} -.dark-theme .app-footer { - background: linear-gradient(180deg, #0b1220, #050b14); -} - -.footer-container { - margin: auto; - display: flex; - gap: 60px; - justify-content: space-between; - flex-wrap: wrap; -} - -.footer-brand { - max-width: 420px; -} - -.footer-logo { - width: 42px; - height: 42px; - background: linear-gradient(135deg, #2563eb, #5ab9ff); - border-radius: 14px; - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; -} -.dark-theme .footer-brand h3, -.dark-theme .footer-brand p, -.dark-theme .footer-col h4, -.dark-theme .app-footer a, -.dark-theme .footer-bottom { - color: #f8fafc; -} - -.footer-brand h3 { - margin: 0; - color: #000000; - font-size: 22px; - font-weight: 700; -} - -.footer-brand p { - margin-top: 8px; - color: #000000; - line-height: 1.6; - font-size: 14px; -} - -/* MAIN CONTENT */ - -/* FOOTER */ -.app-footer { - margin-top: auto; -} - -/* GitHub button */ -.github-btn { - display: inline-flex; - align-items: center; - gap: 2px; - margin-top: 1px; - padding: 10px 16px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.06); - color: #000000; - text-decoration: none; - font-weight: 600; - transition: all 0.25s ease; -} - -.github-btn:hover { - background: black(11, 1, 1); - transform: translateY(-2px); -} - -.footer-links { - display: flex; - gap: 80px; - flex-wrap: wrap; -} - -.footer-col h4 { - margin-bottom: 14px; - font-size: 16px; - color: #000000; - font-weight: 700; -} - -.footer-col a { - display: block; - text-decoration: none; - color: #000000; - margin-bottom: 10px; - font-size: 14px; - transition: color 0.2s ease; -} - -.footer-col a:hover { - text-decoration: underline; -} - -/* Bottom */ -.footer-bottom { - margin-top: 10px; - border-top: 1px solid rgba(255, 255, 255, 0.153); - padding-top: 8px; - padding-bottom: 1px; - text-align: center; - font-size: 14px; - color: #080808; -} -.footer-bottom a { - color: #030000; - font-weight: 600; - text-decoration: none; -} - -.footer-bottom a:hover { - text-decoration: underline; -} - -/* Responsive */ -@media (max-width: 768px) { - .footer-container { - flex-direction: column; - gap: 40px; - } - - .footer-links { - gap: 40px; - } -} -/* REMOVE white line above footer in dark mode */ -footer { - margin-top: 0 !important; -} -.recent-table-wrapper { - margin-top: 20px; - width: 100%; - overflow-x: auto; -} - -.recent-table { - width: 100%; - border-collapse: collapse; - border-radius: 12px; - overflow: hidden; -} - -.recent-table thead { - background: rgb(0, 0, 0); -} -.recent-table th { - color: rgb(0, 0, 0); - padding: 8px 14px; - text-align: left; - font-size: 16px; -} -.short-code a { - color: #2563eb; - font-weight: 600; - text-decoration: none; -} - -.short-code a:hover { - color: #1d4ed8; - text-decoration: underline; -} -.recent-table td { - color: rgb(34, 48, 77); - padding: 10px 14px; - text-align: left; - font-size: 14px; -} - -.created-time { - font-size: 14px; - color: #374151; - white-space: nowrap; -} - -.time-ago { - color: #374151; - font-size: 13px; - margin-left: 2px; -} -.recent-table th { - font-weight: 700; -} - -.recent-table tbody tr, -th { - background: rgb(255, 255, 255); - border-bottom: 1px solid rgb(0, 0, 0); -} -.dark-theme.recent-table tbody tr, -td { - background: rgba(255, 255, 255, 0.04); - border-bottom: 1px solid rgb(0, 0, 0); -} -.recent-table tbody tr:hover { - background: rgb(196, 196, 196); -} - -/* Short code */ -.short-code { - font-weight: 700; -} - -.original-url { - color: #22c55e; - word-break: break-all; -} - -/* Action buttons */ -.action-col { - display: flex; - gap: 10px; -} - -.action-btn { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - text-decoration: none; - font-size: 16px; - transition: 0.2s ease; -} - -.open-btn { - background: #3b82f6; - color: #fff; -} - -.delete-btn { - background: #ef4444; - color: #fff; -} - -.recent-table-wrapper { - margin-bottom: 20px; -} -/* ========================= - Coming Soon Page -========================= */ - -.coming-soon-page { - display: flex; - align-items: center; - justify-content: center; - padding: 120px 20px 60px; -} - -.coming-soon-card { - max-width: 520px; - width: 100%; - background: var(--card); - border-radius: 16px; - padding: 50px 40px; - text-align: center; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); -} - -.coming-icon { - font-size: 48px; - margin-bottom: 18px; -} - -.coming-soon-card h1 { - font-size: 34px; - margin-bottom: 14px; - color: #000; -} - -.dark-theme .coming-soon-card h1 { - color: #fff; -} - -.coming-soon-card p { - font-size: 15px; - color: var(--muted); - line-height: 1.6; - margin-bottom: 28px; -} - -.coming-btn { - display: inline-block; - padding: 12px 22px; - border-radius: 10px; - background: var(--accent-grad); - color: #fff; - font-weight: 700; - text-decoration: none; - transition: 0.25s ease; -} - -.coming-btn:hover { - transform: scale(1.05); - box-shadow: 0 12px 28px rgba(77, 163, 185, 0.25); -} -.info-box { - margin-bottom: 15px; - padding: 10px; - color: #0e34f6; - border-radius: 8px; - font-weight: 700; -} +html, +body { + height: 100%; + margin: 0; + font-family: Arial; + padding: 0; + font-family: "Poppins", system-ui, Arial, sans-serif; + background: var(--bg); + background-size: cover; + background-position: center; + background-size: cover; + background-position: center; +} + +input { + width: 70%; + margin-top: 2px; + margin-bottom: 2px; + font-size: 16px; +} + +.admin-box { + margin: 120px auto 60px; + /* space from header + footer */ +} + +.app-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + margin-top: var(--header-height); +} + +button { + padding: 8px; + margin: 5px; +} + +.error-box { + margin-bottom: 15px; + padding: 10px; + color: #ff4d4d; + border-radius: 8px; + font-weight: 600; +} + +.dark-theme h1 { + background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); +} + +.dark-theme p { + background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); +} + +.dark-theme { + --bg-overlay: rgba(0, 0, 0, 0.75); + --glass-bg: rgba(0, 0, 0, 0.4); + --text-color: #fff; + --input-bg: rgba(50, 50, 50, 0.8); + --input-text-color: #fff; +} + +@keyframes pop { + 0% { + transform: scale(0.7); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* INPUT CONTAINER */ +.input-field { + flex: 1 1 700px; + display: flex; + align-items: center; + gap: 12px; + border-radius: 12px; + border: 2px solid rgb(6, 0, 0); + background: transparent; + /* IMPORTANT */ + padding: 12px 12px; +} + +.dark-theme .input-field { + border-color: #ffffff; +} + +/* INPUT ITSELF */ +.input-field input[type="text"] { + width: 100%; + border: none; + outline: none; + background-color: transparent !important; + background-image: none !important; + box-shadow: none !important; + font-size: 23px; +} + +.input-field input { + color: #000 !important; +} + +.dark-theme .input-field input { + color: #fff !important; +} + +.input-field input:-webkit-autofill, +.input-field input:-webkit-autofill:hover, +.input-field input:-webkit-autofill:focus, +.input-field input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 1000px transparent inset !important; + box-shadow: 0 0 0 1000px transparent inset !important; + background-color: transparent !important; + background-image: none !important; + transition: background-color 9999s ease-in-out 0s; +} + +.input-field input:-webkit-autofill { + -webkit-text-fill-color: #000 !important; +} + +.dark-theme .input-field input:-webkit-autofill { + -webkit-text-fill-color: #fff !important; +} + +.input-field input::selection, +.input-field input::-moz-selection { + background: transparent; + color: inherit; +} + +.short-code { + color: #0a0000; + /* blue like links */ + font-weight: 700; +} + +.app-header { + position: fixed; + top: 0; + left: 0; + width: 97%; + height: 55px; + background: white; + display: flex; + align-items: center; + padding: 0 28px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + z-index: 1000; +} + +/* Dark mode */ +.dark-theme .app-header { + background: linear-gradient(180deg, #0b1220, #050b14); +} + +footer { + margin-top: 0; +} + +body.dark-theme, +body.dark-theme .page, +body.dark-theme main, +body.dark-theme section { + background: #0f1720 !important; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.app-logo { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #2563eb, #5ab9ff); + color: white; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.app-name { + font-size: 20px; + font-weight: 700; + color: #111827; +} + +.dark-theme .app-name { + color: #f8fafc; +} + +.header-nav { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 26px; +} + +.nav-link { + text-decoration: none; + color: #111827; + font-weight: 500; + position: relative; +} + +.dark-theme .nav-link { + color: #e5e7eb; +} + +.nav-link:hover { + color: #2563eb; +} + +.nav-link.active::after { + content: ""; + position: absolute; + bottom: -6px; + left: 0; + width: 100%; + height: 2px; + background: #111827; +} + +.dark-theme .nav-link.active::after { + background: #f8fafc; +} + +.header-right { + margin-left: auto; + display: flex; + align-items: center; +} + +/*:root { + --header-height: 55px; + --bg: #eefaf8; + --card: rgba(255, 255, 255, 0.95); + --muted: #7b8b8a; + --accent-1: #5ab9ff; + --accent-2: #4cb39f; + --accent-grad: linear-gradient(90deg, #4cb39f, #5ab9ff); + --success: #2fb06e; + --glass: rgba(255, 255, 255, 0.85); +}*/ + +:root { + /* Background */ + --bg: #eefaf8; + --card: rgba(255, 255, 255, 0.85); + + /* Text */ + --text-color: #1f2937; + --text-muted: #6b7280; + + /* Borders */ + --glass-border: rgba(0, 0, 0, 0.08); + + /* Accent */ + --accent-1: #5ab9ff; + --accent-2: #4cb39f; + + /* Shadow */ + --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.12); +} + +.dark-theme { + --bg-overlay: rgba(0, 0, 0, 0.75); + --glass-bg: rgba(0, 0, 0, 0.4); + --text-color: #f3f3f3; + --input-bg: rgba(11, 10, 10, 0.8); + --button-bg: linear-gradient(90deg, #4444ff, #2266ff); + --recent-bg: rgba(255, 255, 255, 0.1); +} + +/* Preserve your dark theme variables too */ +body.dark-theme { + --bg: #0f1720; + --card: rgba(20, 25, 30, 0.75); + + --text-color: #e5e7eb; + --text-muted: #9aa7a6; + + --glass-border: rgba(255, 255, 255, 0.08); + + --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); +} + +.page { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + min-height: 80vh; +} + +.theme-toggle { + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 8px; + font-weight: 700; + background: var(--card); +} + +/* Hero */ +.hero { + width: 100%; + max-width: 1100px; + background: transparent; + text-align: center; + padding: 10px; +} + +.hero h1 { + margin: 10px 0 14px; + font-size: 36px; + line-height: 1.05; + color: #000606; +} + +.hero p { + margin: var(--bg-overlay); + color: var(--muted); + max-width: 820px; + margin-left: auto; + margin-right: auto; + color: #000606; +} + +/* Main card & input */ +.card { + width: 100%; + max-width: 1100px; + background: var(--card); + border-radius: 14px; + padding: 15px; + box-shadow: 0 18px 50px rgba(8, 24, 24, 0.06); +} + +.cta { + min-width: 220px; + padding: 14px 22px; + border-radius: 12px; + border: none; + color: rgb(12, 1, 1); + font-weight: 700; + cursor: pointer; + background: var(--accent-grad); + box-shadow: 0 12px 28px rgba(77, 163, 185, 0.12); +} + +.small-action { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + margin-top: 10px; +} + +.result { + margin-top: 26px; + background: white; + border-radius: 12px; + padding: 20px; + border: 1px solid rgba(22, 60, 55, 0.03); + box-shadow: 0 8px 28px rgba(7, 20, 20, 0.03); +} + +.result-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.result-header .dot { + width: 30px; + height: 30px; + background: var(--success); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; +} + +.short-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(75, 194, 176, 0.06), rgba(94, 207, 255, 0.04)); +} + +.short-box input { + align-items: center; + padding: 10px; + font-size: 15px; +} + +.btn-copy { + border: none; + padding: 10px 14px; + border-radius: 8px; + color: white; + font-weight: 700; + cursor: pointer; +} + +.btn-share { + background: #f2f5f5; + border: none; + padding: 10px 14px; + border-radius: 8px; + color: #0b2b2a; + font-weight: 700; + cursor: pointer; + margin-left: 6px; +} + +.meta-row { + align-items: center; + justify-content: center; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + padding: 16px; + margin-top: 1px; + align-items: top; + color: black; +} + +.result-body { + margin-top: 30px; + + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.qr-block { + text-align: center; + padding-top: 8px; +} + +.qr-block img { + height: 15rem; + align-items: center; + aspect-ratio: 1; + box-shadow: 0 10px 20px rgba(10, 20, 30, 0.06); + outline: 2px solid green; + outline-offset: 4px; +} + +.download-qr { + display: inline-block; + margin-top: 12px; + text-decoration: none; + color: var(--accent-1); + font-weight: 700; +} + +.action-row { + display: flex; + justify-content: right; + align-items: right; +} + +.action-secondary { + background: #f6fbfb; + border: 1px solid rgba(0, 0, 0, 0.03); + border-radius: 10px; + cursor: pointer; + font-weight: 700; +} + +/* Force Generate QR to stay on one line */ +.qr-inline { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.qr-inline input { + margin: 0; +} + +/* Responsive */ +@media (max-width: 880px) { + .input-row { + flex-direction: column; + } + + .cta { + width: 100%; + } + + .meta-row { + grid-template-columns: 1fr; + } +} + +.result-title { + font-weight: 700; + color: #0e34f6; +} + +.dark-theme .result-title { + color: #150cff; +} + +footer { + min-height: auto; +} + +.app-footer { + background: white; + color: #e5e7eb; + padding: 8px 10px; + margin-top: auto; + position: relative; +} + +.dark-theme .app-footer { + background: linear-gradient(180deg, #0b1220, #050b14); +} + +.footer-container { + margin: auto; + display: flex; + gap: 60px; + justify-content: space-between; + flex-wrap: wrap; +} + +.footer-brand { + max-width: 420px; +} + +.footer-logo { + width: 42px; + height: 42px; + background: linear-gradient(135deg, #2563eb, #5ab9ff); + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; +} + +.dark-theme .footer-brand h3, +.dark-theme .footer-brand p, +.dark-theme .footer-col h4, +.dark-theme .app-footer a, +.dark-theme .footer-bottom { + color: #f8fafc; +} + +.footer-brand h3 { + margin: 0; + color: #000000; + font-size: 22px; + font-weight: 700; +} + +.footer-brand p { + margin-top: 8px; + color: #000000; + line-height: 1.6; + font-size: 14px; +} + +/* MAIN CONTENT */ + +/* FOOTER */ +.app-footer { + margin-top: auto; +} + +/* GitHub button */ +.github-btn { + display: inline-flex; + align-items: center; + gap: 2px; + margin-top: 1px; + padding: 10px 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + color: #000000; + text-decoration: none; + font-weight: 600; + transition: all 0.25s ease; +} + +.github-btn:hover { + background: black(11, 1, 1); + transform: translateY(-2px); +} + +.footer-links { + display: flex; + gap: 80px; + flex-wrap: wrap; +} + +.footer-col h4 { + margin-bottom: 14px; + font-size: 16px; + color: #000000; + font-weight: 700; +} + +.footer-col a { + display: block; + text-decoration: none; + color: #000000; + margin-bottom: 10px; + font-size: 14px; + transition: color 0.2s ease; +} + +.footer-col a:hover { + text-decoration: underline; +} + +/* Bottom */ +.footer-bottom { + margin-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.153); + padding-top: 8px; + padding-bottom: 1px; + text-align: center; + font-size: 14px; + color: #080808; +} + +.footer-bottom a { + color: #030000; + font-weight: 600; + text-decoration: none; +} + +.footer-bottom a:hover { + text-decoration: underline; +} + +/* Responsive */ +@media (max-width: 768px) { + .footer-container { + flex-direction: column; + gap: 40px; + } + + .footer-links { + gap: 40px; + } +} + +/* REMOVE white line above footer in dark mode */ +footer { + margin-top: 0 !important; +} + + +/*=============================== + MODERN GLASS RECENT TABLE +================================ */ + +.recent-page-container { + width: 100%; + max-width: 1100px; + margin: 30px auto; + padding: 28px; + + background: var(--card); + backdrop-filter: blur(20px); + + border: 1px solid var(--glass-border); + border-radius: 20px; + + box-shadow: var(--card-shadow); + color: var(--text-color); + + transition: background 0.3s ease, border 0.3s ease; +} + +.recent-table-wrapper { + margin-top: 20px; + width: 100%; + overflow-x: auto; +} + +/* Table */ +.recent-table { + width: 100%; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; +} + +/* Header */ +.recent-table thead { + background: var(--glass); +} + +.recent-table th { + padding: 8px 14px; + text-align: left; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--muted); + border-bottom: 1px solid var(--glass-border); +} + +/* Body cells */ +.recent-table td { + padding: 14px; + font-size: 14px; + color: var(--text-primary); + border-bottom: 1px solid var(--glass-border); + transition: 0.25s ease; +} + +/* Row hover */ +.recent-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Short link */ +.short-code a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.short-code a:hover { + color: var(--accent-2); + text-decoration: underline; +} + +/* Original URL */ +.original-url { + word-break: break-all; +} + +.original-url a { + color: var(--text-secondary); + text-decoration: none; +} + +.original-url a:hover { + color: var(--accent); +} + +/* Created time */ +.created-time { + font-size: 13px; + color: var(--muted); + white-space: nowrap; +} + +/* Visit count highlight */ +.recent-table td:nth-child(5) { + font-weight: 700; + color: var(--accent-2); +} + +/* Dark mode adjustments */ +.dark-theme .recent-table th, +.dark-theme .recent-table td { + color: #e5e7eb; + border-bottom: 1px solid var(--glass-border); +} + +/* Action buttons */ +.action-col { + display: flex; + gap: 10px; +} + +.action-btn { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 16px; + transition: 0.2s ease; +} + +.open-btn { + background: #3b82f6; + color: #fff; +} + +.delete-btn { + background: #ef4444; + color: #fff; +} + +.recent-table-wrapper { + margin-bottom: 20px; +} + + +/* ========================= + Coming Soon Page +========================= */ + +.coming-soon-page { + display: flex; + align-items: center; + justify-content: center; + padding: 120px 20px 60px; +} + +.coming-soon-card { + max-width: 520px; + width: 100%; + background: var(--card); + border-radius: 16px; + padding: 50px 40px; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); +} + +.coming-icon { + font-size: 48px; + margin-bottom: 18px; +} + +.coming-soon-card h1 { + font-size: 34px; + margin-bottom: 14px; + color: #000; +} + +.dark-theme .coming-soon-card h1 { + color: #fff; +} + +.coming-soon-card p { + font-size: 15px; + color: var(--muted); + line-height: 1.6; + margin-bottom: 28px; +} + +.coming-btn { + display: inline-block; + padding: 12px 22px; + border-radius: 10px; + background: var(--accent-grad); + color: #fff; + font-weight: 700; + text-decoration: none; + transition: 0.25s ease; +} + +.coming-btn:hover { + transform: scale(1.05); + box-shadow: 0 12px 28px rgba(77, 163, 185, 0.25); +} + +.info-box { + margin-bottom: 15px; + padding: 10px; + color: #0e34f6; + border-radius: 8px; + font-weight: 700; +} \ No newline at end of file diff --git a/app/templates/recent.html b/app/templates/recent.html index 26c5d69..d20b35a 100644 --- a/app/templates/recent.html +++ b/app/templates/recent.html @@ -10,7 +10,7 @@ - +
@@ -205,6 +205,21 @@

Support

+ + \ No newline at end of file diff --git a/app/utils/cache.py b/app/utils/cache.py index a6f1c6a..9150f4a 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -7,6 +7,7 @@ class UrlCacheItem(TypedDict): url: str expires_at: float + visit_count: int class RevCacheItem(TypedDict): @@ -60,8 +61,9 @@ def set_cache_pair(short_code: str, original_url: str) -> None: expires_at = now + CACHE_TTL url_cache[short_code] = { - "url": original_url, - "expires_at": expires_at, + "url": original_url, + "expires_at": expires_at, + "visit_count": 0, } rev_cache[original_url] = { @@ -112,13 +114,14 @@ def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: now = _now() items = [ - { - "short_code": data["short_code"], - "original_url": original_url, - } - for original_url, data in rev_cache.items() - if data["expires_at"] >= now - ] + { + "short_code": data["short_code"], + "original_url": original_url, + "visit_count": url_cache.get(data["short_code"], {}).get("visit_count", 0), + } + for original_url, data in rev_cache.items() + if data["expires_at"] >= now + ] items.sort( key=lambda x: rev_cache[x["original_url"]]["last_accessed"], reverse=True diff --git a/app/utils/config.py b/app/utils/config.py index af78124..7bd3cec 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,14 +1,15 @@ - import os + # ------------------------- # Helpers # ------------------------- -from app.utils.config_env import load_env # noqa: F401 +from app.utils.config_env import load_env # noqa: F401 load_env() + def _get_int(key: str, default: int) -> int: try: return int(os.getenv(key, default)) diff --git a/app/utils/db.py b/app/utils/db.py index be7ed0c..eb9453d 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -34,10 +34,10 @@ def connect_db(max_retries: Optional[int] = None) -> bool: """ Connect to MongoDB with retry logic and exponential backoff. - + Args: max_retries: Maximum number of retry attempts (defaults to config value) - + Returns: True if connection successful, False otherwise """ @@ -68,8 +68,10 @@ def connect_db(max_retries: Optional[int] = None) -> bool: for attempt in range(1, max_retries + 1): connection_state = "CONNECTING" last_connection_attempt = datetime.utcnow() - - logger.info(f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})...") + + logger.info( + f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})..." + ) try: # Create MongoClient with timeout and pool settings @@ -80,17 +82,17 @@ def connect_db(max_retries: Optional[int] = None) -> bool: minPoolSize=MONGO_MIN_POOL_SIZE, maxPoolSize=MONGO_MAX_POOL_SIZE, ) - + # Validate connection with ping new_client.admin.command("ping") - + # Connection successful client = new_client db = new_client[MONGO_DB_NAME] collection = db[MONGO_COLLECTION] connection_state = "CONNECTED" connection_error = None - + logger.info("Successfully connected to MongoDB") return True @@ -98,7 +100,7 @@ def connect_db(max_retries: Optional[int] = None) -> bool: error_msg = f"Connection attempt {attempt} failed: {str(e)}" logger.warning(error_msg) connection_error = str(e) - + if attempt < max_retries: logger.info(f"Retrying in {retry_delay:.1f} seconds...") time.sleep(retry_delay) @@ -120,7 +122,9 @@ def get_connection_state() -> dict[str, Any]: """Return current connection state information.""" return { "state": connection_state, - "last_attempt": last_connection_attempt.isoformat() if last_connection_attempt else None, + "last_attempt": ( + last_connection_attempt.isoformat() if last_connection_attempt else None + ), "error": connection_error, "connected": is_connected(), } @@ -227,23 +231,23 @@ def increment_visit(short_code: str) -> Optional[dict]: async def health_check_loop() -> None: """Background task that periodically checks database connection health.""" global connection_state, connection_error - + from app.utils.config import HEALTH_CHECK_INTERVAL_SECONDS - + logger.info("Health check loop started") - + try: while True: await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS) - + logger.debug("Running health check...") - + # If disconnected, try to reconnect if not is_connected(): logger.info("Database disconnected, attempting reconnection...") connect_db() continue - + # Validate active connection with ping try: if client is not None: @@ -253,7 +257,7 @@ async def health_check_loop() -> None: logger.error(f"Health check failed: {str(e)}") connection_state = "FAILED" connection_error = str(e) - + except asyncio.CancelledError: logger.info("Health check loop cancelled") raise @@ -262,7 +266,7 @@ async def health_check_loop() -> None: def start_health_check() -> Any: """Start the background health check task.""" global health_check_task - + health_check_task = asyncio.create_task(health_check_loop()) logger.info("Health check task started") return health_check_task @@ -271,7 +275,7 @@ def start_health_check() -> Any: async def stop_health_check() -> None: """Stop the background health check task.""" global health_check_task - + if health_check_task is not None: logger.info("Stopping health check task...") health_check_task.cancel() From 401d1d18e8cb877facf60079fe846e1a91dc8632 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Tue, 24 Feb 2026 21:48:28 +0530 Subject: [PATCH 10/18] [RTY-260025]: feat(cache): enhance cache management with created_at timestamp --- app/templates/recent.html | 20 ++++-- app/utils/cache.py | 140 ++++++++++++++++---------------------- 2 files changed, 73 insertions(+), 87 deletions(-) diff --git a/app/templates/recent.html b/app/templates/recent.html index 26c5d69..5d89d19 100644 --- a/app/templates/recent.html +++ b/app/templates/recent.html @@ -185,16 +185,25 @@

Support

function deleteUrl(shortCode, btn) { if (!confirm("Are you sure you want to delete this URL?")) return; - fetch(`/delete/${shortCode}`, { - method: "POST" + fetch(`/recent/${shortCode}`, { + method: "DELETE" }) - .then(res => { - if (!res.ok) throw new Error("Delete failed"); + .then(async (res) => { + const data = await res.json(); + + if (!res.ok || !data.success) { + throw new Error("Delete failed"); + } + const row = btn.closest("tr"); if (row) row.remove(); }) - .catch(() => alert("Failed to delete URL")); + .catch((err) => { + console.error(err); + alert("Failed to delete URL"); + }); } + function toggleRecent() { const box = document.getElementById('recentList'); box.style.display = box.style.display === 'block' ? 'none' : 'block'; @@ -202,7 +211,6 @@

Support

box.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } - diff --git a/app/utils/cache.py b/app/utils/cache.py index 14027b4..a7ef4c2 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -1,6 +1,7 @@ import time from typing import TypedDict - +from datetime import datetime +from zoneinfo import ZoneInfo from app.utils.config import CACHE_TTL, MAX_RECENT_URLS @@ -12,9 +13,14 @@ class UrlCacheItem(TypedDict): class RevCacheItem(TypedDict): short_code: str expires_at: float + created_at: float last_accessed: float +# ----------------------- +# Performance caches (TTL) +# ----------------------- + # short_code -> original_url url_cache: dict[str, UrlCacheItem] = {} @@ -31,24 +37,21 @@ def _now() -> float: # ----------------------- -def _delete_pair_by_short_code(short_code: str) -> None: - """ - Remove both sides of cache using short_code. - """ - data = url_cache.pop(short_code, None) - if data: - original_url = data["url"] - rev_cache.pop(original_url, None) +def set_cache_pair(short_code: str, original_url: str) -> None: + now = _now() + expires_at = now + CACHE_TTL + url_cache[short_code] = { + "url": original_url, + "expires_at": expires_at, + } -def _delete_pair_by_url(original_url: str) -> None: - """ - Remove both sides of cache using original_url. - """ - data = rev_cache.pop(original_url, None) - if data: - short_code = data["short_code"] - url_cache.pop(short_code, None) + rev_cache[original_url] = { + "short_code": short_code, + "expires_at": expires_at, + "created_at": now, + "last_accessed": now, + } def get_from_cache(short_code: str) -> str | None: @@ -58,7 +61,8 @@ def get_from_cache(short_code: str) -> str | None: return None if data["expires_at"] < _now(): - _delete_pair_by_short_code(short_code) + url_cache.pop(short_code, None) + _remove_recent_if_exists(short_code) return None return data["url"] @@ -71,99 +75,70 @@ def get_short_from_cache(original_url: str) -> str | None: return None if data["expires_at"] < _now(): - _delete_pair_by_url(original_url) + rev_cache.pop(original_url, None) return None - # Touch for recent tracking data["last_accessed"] = _now() return data["short_code"] -def set_cache_pair(short_code: str, original_url: str) -> None: +def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: now = _now() - expires_at = now + CACHE_TTL - - url_cache[short_code] = { - "url": original_url, - "expires_at": expires_at, - } - - rev_cache[original_url] = { - "short_code": short_code, - "expires_at": expires_at, - "last_accessed": now, - } - - -def clear_cache() -> None: - """ - Useful for tests or if DB goes down and you want to reset cache. - """ - url_cache.clear() - rev_cache.clear() + valid_items = [ + { + "short_code": data["short_code"], + "original_url": original_url, + "created_at": data["created_at"], + } + for original_url, data in rev_cache.items() + if data["expires_at"] >= now + ] -# ----------------------- -# TTL Cleanup (ACTIVE) -# ----------------------- + valid_items.sort(key=lambda x: x["created_at"], reverse=True) + return valid_items[:limit] def cleanup_expired() -> None: - """ - Actively remove expired cache entries from both caches. - Safe to run periodically in background task. - """ now = _now() expired_short_codes = [ - key for key, value in url_cache.items() if value["expires_at"] < now + short_code for short_code, data in url_cache.items() if data["expires_at"] < now ] for short_code in expired_short_codes: - _delete_pair_by_short_code(short_code) + url_cache.pop(short_code, None) + _remove_recent_if_exists(short_code) - expired_urls = [ - key for key, value in rev_cache.items() if value["expires_at"] < now + expired_original_urls = [ + original_url + for original_url, data in rev_cache.items() + if data["expires_at"] < now ] - for original_url in expired_urls: - _delete_pair_by_url(original_url) - + for original_url in expired_original_urls: + rev_cache.pop(original_url, None) -# ----------------------- -# Recent URLs (derived from rev_cache) -# ----------------------- +def clear_cache() -> None: + url_cache.clear() + rev_cache.clear() -def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: - """ - Returns recent URLs based on cache activity (TTL-aware, no duplicates). - """ - now = _now() - valid_items = [ - { - "short_code": data["short_code"], - "original_url": original_url, - "last_accessed": data["last_accessed"], - } - for original_url, data in rev_cache.items() - if data["expires_at"] >= now - ] +def _remove_recent_if_exists(short_code: str) -> None: + to_delete = None - valid_items.sort(key=lambda x: x["last_accessed"], reverse=True) + for original_url, data in rev_cache.items(): + if data["short_code"] == short_code: + to_delete = original_url + break - return [ - { - "short_code": item["short_code"], - "original_url": item["original_url"], - } - for item in valid_items[:limit] - ] + if to_delete: + rev_cache.pop(to_delete, None) # ----------------------- -# Debug / Introspection +# UI helpers # ----------------------- @@ -177,6 +152,9 @@ def list_cache_clean() -> dict: { "short_code": data["short_code"], "original_url": original_url, + "created_at": datetime.fromtimestamp( + data["created_at"], tz=ZoneInfo("Asia/Kolkata") + ).strftime("%d %b %Y, %I:%M %p"), } for original_url, data in rev_cache.items() if data["expires_at"] >= now From 4acc7370df0ff6b6e1678a0ed9367d1e5b243939 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Wed, 25 Feb 2026 14:22:25 +0530 Subject: [PATCH 11/18] [RTY-260025]: feat(cache): adjust cache cleanup interval based on CACHE_TTL --- README.md | 2 +- app/main.py | 14 +++++++++++--- app/utils/config.py | 1 - app/utils/helper.py | 31 +++++++++++++++++++++++++------ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6c65895..a668a74 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ pip install --upgrade dist/*.whl | GET | `/` | Home page (URL shortener UI) | | GET | `/recent` | Shows recently shortened URLs | | GET | `/{short_code}` | Redirects to the original URL | -| GET | `/debug/cache` | πŸ”§ Debug cache view (local/dev only) | +| GET | `/cache/list` | πŸ”§ Debug cache view (local/dev only) | πŸ”Œ API Endpoints (v1) diff --git a/app/main.py b/app/main.py index 5af8621..c960eeb 100644 --- a/app/main.py +++ b/app/main.py @@ -13,22 +13,30 @@ from app.routes import ui_router from app.utils import db from app.utils.cache import cleanup_expired -from app.utils.config import SESSION_SECRET - # ----------------------------- # Background cache cleanup task # ----------------------------- +from app.utils.config import ( + CACHE_TTL, + SESSION_SECRET, +) + + async def cache_health_check(): logger = logging.getLogger(__name__) logger.info("🧹 Cache cleanup task started") + interval = max(1, CACHE_TTL // 3) # pure TTL-based + + logger.info(f"πŸ•’ Cache cleanup interval set to {interval}s") + while True: try: cleanup_expired() except Exception as e: logger.error(f"Cache cleanup error: {e}") - await asyncio.sleep(5) # cleanup every 5 seconds + await asyncio.sleep(interval) # ----------------------------- diff --git a/app/utils/config.py b/app/utils/config.py index 7bd3cec..9d71811 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -64,7 +64,6 @@ def _get_int(key: str, default: int) -> int: # ------------------------- USE_CACHE = True CACHE_TTL = 900 # 15 minutes -MAX_CACHE_SIZE = 10_000 MAX_RECENT_URLS = 20 # ------------------------- diff --git a/app/utils/helper.py b/app/utils/helper.py index 30be0b2..1241ad2 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,6 +1,9 @@ import string import random -from datetime import timezone +from datetime import datetime, timezone +from zoneinfo import ZoneInfo +from typing import Union + import validators from app.utils.config import SHORT_CODE_LENGTH @@ -23,11 +26,27 @@ def generate_code(length: int = SHORT_CODE_LENGTH) -> str: return "".join(random.choice(chars) for _ in range(length)) -def format_date(dt): - if not dt: +def format_date(value: Union[float, datetime, None]) -> str: + """ + Formats both: + - float/int epoch timestamps (from cache) + - datetime objects (from DB) + into: '24 Feb 2026, 03:59 PM' (IST) + """ + if not value: return "Just now" - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + # If cache timestamp (epoch seconds) + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value, tz=ZoneInfo("Asia/Kolkata")).strftime( + "%d %b %Y, %I:%M %p" + ) + + # If DB datetime + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + + return value.astimezone(ZoneInfo("Asia/Kolkata")).strftime("%d %b %Y, %I:%M %p") - return dt.strftime("%d %b %Y, %I:%M %p") + return "Just now" From 27ed5de8bc35f75c8377fd92f4e06867f41ace2d Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Wed, 25 Feb 2026 20:25:55 +0530 Subject: [PATCH 12/18] [RTY-260025]: feat(cache): allow deleting single cache entry by short code or original URL --- app/routes.py | 74 ++++++++++++++++++++++++++++++++++------------ app/utils/cache.py | 7 +++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/app/routes.py b/app/routes.py index 4fc7ea2..6c43297 100644 --- a/app/routes.py +++ b/app/routes.py @@ -3,7 +3,15 @@ from pathlib import Path from typing import Optional from app.utils.cache import list_cache_clean, clear_cache -from fastapi import APIRouter, Form, Request, status, HTTPException +from fastapi import ( + APIRouter, + Form, + Request, + status, + HTTPException, + Query, + BackgroundTasks, +) from fastapi.responses import ( HTMLResponse, PlainTextResponse, @@ -20,7 +28,9 @@ get_recent_from_cache, get_short_from_cache, set_cache_pair, + increment_visit_cache, url_cache, + rev_cache, ) from app.utils.config import DOMAIN, MAX_RECENT_URLS from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date @@ -134,7 +144,13 @@ async def recent_urls(request: Request): return templates.TemplateResponse( "recent.html", - {"request": request, "urls": recent_urls_list, "format_date": format_date}, + { + "request": request, + "urls": recent_urls_list, + "format_date": format_date, + "db_available": db.get_collection() is not None, + "get_visit_count_from_cache": increment_visit_cache, + }, ) @@ -144,10 +160,12 @@ def cache_list_ui(): @ui_router.post("/cache/clean") -def cache_clean_ui(key: str): +def cache_clean_ui( + key: str = Query(..., description="CLEAR_ALL | short_code | original_url"), +): """ - key=CLEAR_ALL -> force delete everything from cache - key=FORCE_ONE -> force delete one entry (requires short_code or original_url) + key=CLEAR_ALL -> force delete everything from cache + key= -> force delete one entry from cache """ if key == "CLEAR_ALL": clear_cache() # πŸ”₯ force wipe all cache @@ -157,13 +175,43 @@ def cache_clean_ui(key: str): **list_cache_clean(), } - raise HTTPException(400, "Invalid key. Use key=CLEAR_ALL") + removed = False + + # Try deleting by short_code + data = url_cache.pop(key, None) + if data: + rev_cache.pop(data["url"], None) + removed = True + + # Try deleting by original_url + if not removed: + data = rev_cache.pop(key, None) + if data: + url_cache.pop(data["short_code"], None) + removed = True + + if removed: + return { + "status": "deleted", + "strategy": "FORCE_ONE", + "key": key, + **list_cache_clean(), + } + + raise HTTPException( + 404, + "Key not found in cache. Use key=CLEAR_ALL or a valid short_code/original_url.", + ) @ui_router.get("/{short_code}") -def redirect_short_ui(short_code: str): +def redirect_short_ui(short_code: str, background_tasks: BackgroundTasks): cached_url = get_from_cache(short_code) if cached_url: + if db.is_connected(): + background_tasks.add_task(db.increment_visit, short_code) + else: + increment_visit_cache(short_code) return RedirectResponse(cached_url) if db.is_connected(): @@ -172,15 +220,6 @@ def redirect_short_ui(short_code: str): set_cache_pair(short_code, doc["original_url"]) return RedirectResponse(doc["original_url"]) - recent_db = db.get_recent_urls(MAX_RECENT_URLS) - for item in recent_db or []: - code = item.get("short_code") or item.get("code") - if code == short_code: - original_url = item.get("original_url") - if original_url: - set_cache_pair(short_code, original_url) - return RedirectResponse(original_url) - recent_cache = get_recent_from_cache(MAX_RECENT_URLS) for item in recent_cache or []: code = item.get("short_code") or item.get("code") @@ -200,7 +239,6 @@ def delete_recent_api(short_code: str): UI should never fail if DB is down. """ - # 1️⃣ Remove from cache (source of truth for UI) recent = get_recent_from_cache(MAX_RECENT_URLS) removed_from_cache = False @@ -211,12 +249,10 @@ def delete_recent_api(short_code: str): removed_from_cache = True break - # 2️⃣ Best-effort DB delete db_deleted = False if db.is_connected(): db_deleted = db.delete_by_short_code(short_code) - # 3️⃣ Always succeed for UI return { "success": True, "removed_from_cache": removed_from_cache, diff --git a/app/utils/cache.py b/app/utils/cache.py index a7ef4c2..ab57acb 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -27,6 +27,9 @@ class RevCacheItem(TypedDict): # original_url -> short_code (+ metadata for recent tracking) rev_cache: dict[str, RevCacheItem] = {} +# short_code -> visit_count (temporary, in-memory) +visit_cache: dict[str, int] = {} + def _now() -> float: return time.time() @@ -54,6 +57,10 @@ def set_cache_pair(short_code: str, original_url: str) -> None: } +def increment_visit_cache(short_code: str) -> None: + visit_cache[short_code] = visit_cache.get(short_code, 0) + 1 + + def get_from_cache(short_code: str) -> str | None: data = url_cache.get(short_code) From c09a8715ab73839dbff70d447321838077d3efa4 Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Thu, 26 Feb 2026 16:50:00 +0530 Subject: [PATCH 13/18] [RTY-260025]: feat(cache): add header security for cache purge and remove endpoints --- README.md | 31 +++++++++++++++----- app/routes.py | 69 +++++++++++++++++++------------------------ app/utils/cache.py | 71 ++++++++++++++++++++++++++++++++++++++------- app/utils/config.py | 3 ++ 4 files changed, 117 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index a668a74..bab2b73 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,18 @@ pip install dist/*.whl pip install --upgrade dist/*.whl ``` -`πŸ“‘ Endpoints` +# πŸ“‘ Endpoints + +# πŸ” Cache Admin Endpoints (Authentication) + +To use the cache admin endpoints (`/cache/purge`, `/cache/remove`), you must configure a secret token in your environment and send it in the request header. +Setup + +Add a token in your .env file: + +``` +CACHE_PURGE_TOKEN=your-secret-token +``` πŸ–₯️ UI Endpoints @@ -278,16 +289,20 @@ pip install --upgrade dist/*.whl | GET | `/recent` | Shows recently shortened URLs | | GET | `/{short_code}` | Redirects to the original URL | | GET | `/cache/list` | πŸ”§ Debug cache view (local/dev only) | +| DELETE | `/cache/purge` | 🧹 Remove all entries from cache | +| PATCH | `/cache/remove` | 🧹 Remove a single cache entry | πŸ”Œ API Endpoints (v1) -| Method | Path | Description | -| ------ | ------------------- | -------------------------------- | -| POST | `/api/v1/shorten` | Create a short URL | -| GET | `/api/v1/version` | Get API version | -| GET | `/api/v1/health` | Health check (DB + cache status) | -| GET | `/api/_debug/cache` | πŸ”§ Debug cache view (dev only) | -| GET | `/api/{short_code}` | Redirect to original URL | +| Method | Path | Description | +| ------ | ------------------- | ------------------------------------ | +| POST | `/api/v1/shorten` | Create a short URL | +| GET | `/api/v1/version` | Get API version | +| GET | `/api/v1/health` | Health check (DB + cache status) | +| GET | `/api/{short_code}` | Redirect to original URL | +| GET | `/cache/list` | πŸ”§ Debug cache view (local/dev only) | +| DELETE | `/cache/purge` | 🧹 Remove all entries from cache | +| PATCH | `/cache/remove` | 🧹 Remove a single cache entry | ## License diff --git a/app/routes.py b/app/routes.py index 6c43297..d7e087d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -9,8 +9,9 @@ Request, status, HTTPException, - Query, BackgroundTasks, + Header, + Query, ) from fastapi.responses import ( HTMLResponse, @@ -30,9 +31,9 @@ set_cache_pair, increment_visit_cache, url_cache, - rev_cache, + remove_cache_key, ) -from app.utils.config import DOMAIN, MAX_RECENT_URLS +from app.utils.config import DOMAIN, MAX_RECENT_URLS, CACHE_PURGE_TOKEN from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date from app.utils.qr import generate_qr_with_logo @@ -159,49 +160,39 @@ def cache_list_ui(): return list_cache_clean() -@ui_router.post("/cache/clean") -def cache_clean_ui( - key: str = Query(..., description="CLEAR_ALL | short_code | original_url"), -): +@ui_router.delete("/cache/purge", response_class=PlainTextResponse) +def cache_purge_ui(cache_token: str = Header(...)): """ - key=CLEAR_ALL -> force delete everything from cache - key= -> force delete one entry from cache + Force delete everything from cache (secured by header) """ - if key == "CLEAR_ALL": - clear_cache() # πŸ”₯ force wipe all cache - return { - "status": "cleared", - "strategy": "FORCE_FULL_RESET", - **list_cache_clean(), - } + if cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") - removed = False + clear_cache() - # Try deleting by short_code - data = url_cache.pop(key, None) - if data: - rev_cache.pop(data["url"], None) - removed = True + return "cleared ALL" + + +@ui_router.patch("/cache/remove") +def cache_remove_one_ui( + key: str = Query(..., description="short_code OR original_url"), + cache_token: str = Header(...), +): + # πŸ” Header security + if cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") + + removed = remove_cache_key(key) - # Try deleting by original_url if not removed: - data = rev_cache.pop(key, None) - if data: - url_cache.pop(data["short_code"], None) - removed = True - - if removed: - return { - "status": "deleted", - "strategy": "FORCE_ONE", - "key": key, - **list_cache_clean(), - } + raise HTTPException( + status_code=404, + detail="Key not found in cache.", + ) - raise HTTPException( - 404, - "Key not found in cache. Use key=CLEAR_ALL or a valid short_code/original_url.", - ) + return { + "status": "deleted", + } @ui_router.get("/{short_code}") diff --git a/app/utils/cache.py b/app/utils/cache.py index ab57acb..a9ac95a 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -17,6 +17,12 @@ class RevCacheItem(TypedDict): last_accessed: float +class RecentItem(TypedDict): + short_code: str + original_url: str + created_at: float + + # ----------------------- # Performance caches (TTL) # ----------------------- @@ -40,6 +46,25 @@ def _now() -> float: # ----------------------- +def _enforce_recent_limit() -> None: + """ + Ensure rev_cache keeps only MAX_RECENT_URLS most recent items. + Removes the oldest entries by created_at. + """ + if len(rev_cache) <= MAX_RECENT_URLS: + return + + sorted_items = sorted( + rev_cache.items(), + key=lambda item: item[1]["created_at"], + ) + + excess = len(rev_cache) - MAX_RECENT_URLS + for i in range(excess): + original_url, _ = sorted_items[i] + rev_cache.pop(original_url, None) + + def set_cache_pair(short_code: str, original_url: str) -> None: now = _now() expires_at = now + CACHE_TTL @@ -56,6 +81,8 @@ def set_cache_pair(short_code: str, original_url: str) -> None: "last_accessed": now, } + _enforce_recent_limit() + def increment_visit_cache(short_code: str) -> None: visit_cache[short_code] = visit_cache.get(short_code, 0) + 1 @@ -89,18 +116,20 @@ def get_short_from_cache(original_url: str) -> str | None: return data["short_code"] -def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: +def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[RecentItem]: now = _now() - valid_items = [ - { - "short_code": data["short_code"], - "original_url": original_url, - "created_at": data["created_at"], - } - for original_url, data in rev_cache.items() - if data["expires_at"] >= now - ] + valid_items: list[RecentItem] = [] + + for original_url, data in rev_cache.items(): + if data["expires_at"] >= now: + valid_items.append( + { + "short_code": data["short_code"], + "original_url": original_url, + "created_at": data["created_at"], + } + ) valid_items.sort(key=lambda x: x["created_at"], reverse=True) return valid_items[:limit] @@ -173,3 +202,25 @@ def list_cache_clean() -> dict: "MAX_RECENT_URLS": MAX_RECENT_URLS, "CACHE_TTL": CACHE_TTL, } + + +def remove_cache_key(key: str) -> bool: + """ + Remove a cache entry by short_code OR original_url. + """ + is_url = key.startswith("http://") or key.startswith("https://") + + if is_url: + rev_item = rev_cache.pop(key, None) + if rev_item: + url_cache.pop(rev_item["short_code"], None) + visit_cache.pop(rev_item["short_code"], None) + return True + else: + url_item = url_cache.pop(key, None) + if url_item: + rev_cache.pop(url_item["url"], None) + visit_cache.pop(key, None) + return True + + return False diff --git a/app/utils/config.py b/app/utils/config.py index 9d71811..5529f3e 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -71,6 +71,9 @@ def _get_int(key: str, default: int) -> int: # ------------------------- SESSION_SECRET = os.getenv("SESSION_SECRET", "super-secret-key") + +# Security token for cache/purge and cache/remove endpoint (in case we want to trigger it manually) +CACHE_PURGE_TOKEN = os.getenv("CACHE_PURGE_TOKEN", "dev-token") # ------------------------- # Short URL (constants) # ------------------------- From 13220f9e80209d898f1fa91a0ef1014a27d7ad1e Mon Sep 17 00:00:00 2001 From: harshmishra2701 Date: Thu, 26 Feb 2026 20:02:05 +0530 Subject: [PATCH 14/18] [RTY-260025]: feat(docs): add testing instructions and update footer links in README --- README.md | 20 ++++++++++++++++++++ app/routes.py | 4 ++-- app/templates/index.html | 6 +++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bab2b73..e480122 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,26 @@ Add a token in your .env file: CACHE_PURGE_TOKEN=your-secret-token ``` +πŸ§ͺ How to test + +PowerShell + +``` +Invoke-RestMethod ` + -Method DELETE ` + -Uri "http://127.0.0.1:8000/cache/purge" ` + -Headers @{ "X-Cache-Token" = "your-secret-token" } +``` + +🧹 Remove a single cache entry + +``` +Invoke-RestMethod ` + -Method PATCH ` + -Uri "http://127.0.0.1:8000/cache/remove?key=abc123" ` + -Headers @{ "X-Cache-Token" = "your-secret-token" } +``` + πŸ–₯️ UI Endpoints | Method | Path | Description | diff --git a/app/routes.py b/app/routes.py index d7e087d..15cd7b1 100644 --- a/app/routes.py +++ b/app/routes.py @@ -161,7 +161,7 @@ def cache_list_ui(): @ui_router.delete("/cache/purge", response_class=PlainTextResponse) -def cache_purge_ui(cache_token: str = Header(...)): +def cache_purge_ui(cache_token: str = Header(..., alias="Cache-Token")): """ Force delete everything from cache (secured by header) """ @@ -176,7 +176,7 @@ def cache_purge_ui(cache_token: str = Header(...)): @ui_router.patch("/cache/remove") def cache_remove_one_ui( key: str = Query(..., description="short_code OR original_url"), - cache_token: str = Header(...), + cache_token: str = Header(..., alias="Cache-Token"), ): # πŸ” Header security if cache_token != CACHE_PURGE_TOKEN: diff --git a/app/templates/index.html b/app/templates/index.html index 7a5ecd4..986b414 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -28,7 +28,9 @@

tiny URL

@@ -68,6 +70,7 @@

πŸ”— tiny URL