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/.github/actions/comment-on-issue/action.yml b/.github/actions/comment-on-issue/action.yml deleted file mode 100644 index a594127..0000000 --- a/.github/actions/comment-on-issue/action.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Comment on Issue" -description: "Posts a comment on a given issue" - -inputs: - issue_number: - description: "The issue number to comment on" - required: true - -runs: - using: "composite" - steps: - - uses: actions/github-script@v8 - with: - script: | - const issue_number = parseInt('${{ inputs.issue_number }}', 10); - await github.issues.createComment({ - ...context.repo, - issue_number, - body: "๐Ÿ‘‹ Thank you for opening this issue! We will look into it as soon as possible." - }); diff --git a/.github/actions/format-issue-title/action.yml b/.github/actions/format-issue-title/action.yml deleted file mode 100644 index b04df6c..0000000 --- a/.github/actions/format-issue-title/action.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: "Format Issue ID" -description: "Replaces placeholder or appends formatted ID to GitHub issue titles" - -inputs: - prefix: - description: "Prefix for the identifier (e.g., TZF)" - required: true - placeholder: - description: "Optional placeholder to replace (e.g., X)" - required: false - dry_run: - description: "If true, logs the new title but doesn't update" - required: false - default: "false" - -runs: - using: "composite" - steps: - - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - chmod +x "${{ github.action_path }}/format.sh" - "${{ github.action_path }}/format.sh" \ - "${{ github.event.issue.number }}" \ - "${{ github.event.issue.title }}" \ - "${{ inputs.prefix }}" \ - "${{ inputs.placeholder }}" \ - "${{ inputs.dry_run }}" diff --git a/.github/actions/format-issue-title/format.sh b/.github/actions/format-issue-title/format.sh deleted file mode 100755 index e12499f..0000000 --- a/.github/actions/format-issue-title/format.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -set -e - -ISSUE_NUMBER=$1 -OLD_TITLE=$2 -PREFIX=$3 -PLACEHOLDER=$4 -DRY_RUN=$5 - -# Fallback to default if prefix is empty -if [ -z "$PREFIX" ]; then - PREFIX="GEN" -fi - -YEAR=$(date +%y) -PADDED_NUM=$(printf "%04d" "$ISSUE_NUMBER") -IDENTIFIER="[${PREFIX}-${YEAR}${PADDED_NUM}]: " - -# Skip if already present -if echo "$OLD_TITLE" | grep -q "$IDENTIFIER"; then - echo "Identifier already present. Skipping." - exit 0 -fi - -# Replace placeholder at start if provided -if [ -n "$PLACEHOLDER" ] && echo "$OLD_TITLE" | grep -q "^$PLACEHOLDER"; then - NEW_TITLE=$(echo "$OLD_TITLE" | sed "s/^$PLACEHOLDER[[:space:]]*/$IDENTIFIER/") -else - # Insert after emoji if present - if [[ "$OLD_TITLE" =~ ^([[:space:]]*[^[:alnum:][:space:]]+[[:space:]]*)(.*) ]]; then - EMOJI="${BASH_REMATCH[1]}" - REST="${BASH_REMATCH[2]}" - NEW_TITLE="${EMOJI} ${IDENTIFIER}${REST}" - else - NEW_TITLE="${IDENTIFIER}${OLD_TITLE}" - fi -fi - -# Normalize spacing -NEW_TITLE=$(echo "$NEW_TITLE" | sed 's/ */ /g' | sed 's/^ *//;s/ *$//') - -echo "New title would be: $NEW_TITLE" - -if [ "$DRY_RUN" = "true" ]; then - echo "Dry-run enabled. Not updating title." - exit 0 -fi - -gh issue edit "$ISSUE_NUMBER" --title "$NEW_TITLE" --repo "${GITHUB_REPOSITORY}" -echo "Title updated successfully." diff --git a/.github/workflows/comment-on-issue.yml b/.github/workflows/comment-on-issue.yml index 9c52701..b54b4c5 100644 --- a/.github/workflows/comment-on-issue.yml +++ b/.github/workflows/comment-on-issue.yml @@ -10,6 +10,10 @@ on: description: "Issue number" required: true type: number + comment_body: + description: "Comment text" + required: true + type: string permissions: issues: write @@ -20,16 +24,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/github-script@v8 + - name: Run Comment Action + uses: recursivezero/action-club/.github/actions/comment-on-issue@main with: - script: | - // For workflow_dispatch, use the input; for issue events, use context.issue.number - const issue_number = context.payload.inputs?.issue_number - ? parseInt(context.payload.inputs.issue_number, 10) - : context.issue.number; - - await github.rest.issues.createComment({ - ...context.repo, - issue_number, - body: "๐Ÿ‘‹ Thank you for opening this issue! We will look into it as soon as possible." - }); + issue_number: ${{ github.event.inputs.issue_number }} + comment_body: ${{ github.event.inputs.comment_body }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..089f97f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,28 @@ +name: Deploy App +run-name: Deploy Tiny App to Cloud Server + +on: + push: + branches: [release] + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /opt/tz_apps/tiny + # Fetch latest code + git pull origin release + + # Install dependencies (Production mode, no dev deps) + /root/.local/bin/poetry install --only main --all-extras --sync + + # Restart the service + sudo systemctl restart tiny.service diff --git a/.github/workflows/format-issue-title.yml b/.github/workflows/format-issue-title.yml index e1fb2c6..603ef00 100644 --- a/.github/workflows/format-issue-title.yml +++ b/.github/workflows/format-issue-title.yml @@ -1,4 +1,4 @@ -name: "Auto Format Issue Title" +name: "Format Issue Title" on: issues: @@ -11,7 +11,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Format issue title - uses: recursivezero/template/.github/actions/format-issue-title@v2.6 + uses: recursivezero/action-club/.github/actions/format-issue-title@v0.2.57 with: prefix: RTY - dry_run: false diff --git a/.github/workflows/main-ro-release.yml b/.github/workflows/main-ro-release.yml new file mode 100644 index 0000000..ebc5647 --- /dev/null +++ b/.github/workflows/main-ro-release.yml @@ -0,0 +1,37 @@ +name: Sync Main to Release + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required to compare branches + + - name: Check for Code Differences + id: diff_check + run: | + DIFF=$(git diff origin/release...origin/main --name-only) + if [ -z "$DIFF" ]; then + echo "No changes found between main and release. Skipping." + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "## โญ๏ธ Sync Skipped" >> $GITHUB_STEP_SUMMARY + echo "Main and Release are already in sync." >> $GITHUB_STEP_SUMMARY + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Run PR Logic + if: steps.diff_check.outputs.has_changes == 'true' + uses: recursivezero/action-club/.github/actions/release-pr@main + with: + # Use your PAT here if the standard token continues to fail + github_token: ${{ secrets.PROJECT_PAT }} diff --git a/.gitignore b/.gitignore index ae1cf18..8a7644e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ poetry.lock *.tmp *.temp *.bak + + +assets/images/qr/* \ No newline at end of file diff --git a/.vscode/dictionaries/project-words.txt b/.vscode/dictionaries/project-words.txt index 40463fa..5d55556 100755 --- a/.vscode/dictionaries/project-words.txt +++ b/.vscode/dictionaries/project-words.txt @@ -1,2 +1,4 @@ +appleboy projectx +RZRO xkeshav diff --git a/.vscode/dictionaries/team-member.txt b/.vscode/dictionaries/team-member.txt index e488ebd..48cd033 100755 --- a/.vscode/dictionaries/team-member.txt +++ b/.vscode/dictionaries/team-member.txt @@ -1,2 +1,3 @@ keshav mohta +recursivezero diff --git a/CHANGELOG.md b/CHANGELOG.md index ad57eb9..ace03a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,13 @@ All notable changes to this repository will be documented in this file. - Restructure folder structure - added poetry dev script + +## [1.0.0] Sun, Feb 15, 2026 + +- Added In-Memory cache strategy +- DB dependency optional +- Change UI + +## [1.0.3] Wed, Feb 18, 2026 + +- Added Database connection retry logic \ No newline at end of file diff --git a/README.md b/README.md index ff01fba..e480122 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,62 @@ pip install dist/*.whl pip install --upgrade dist/*.whl ``` +# ๐Ÿ“ก 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 +``` + +๐Ÿงช 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 | +| ------ | --------------- | ------------------------------------ | +| GET | `/` | Home page (URL shortener UI) | +| 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/{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 ๐Ÿ“œDocs diff --git a/app/api/fast_api.py b/app/api/fast_api.py index fad63e4..d8d5a99 100644 --- a/app/api/fast_api.py +++ b/app/api/fast_api.py @@ -1,40 +1,19 @@ -import os -import re 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 data as db_data -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, ui_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 +24,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_data.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_data.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_data.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) +app.include_router(api_router) +app.include_router(ui_router) diff --git a/app/main.py b/app/main.py index cea15eb..932e5e4 100644 --- a/app/main.py +++ b/app/main.py @@ -1,242 +1,166 @@ +# app/main.py from contextlib import asynccontextmanager from pathlib import Path -from typing import Optional +import logging +import asyncio -from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +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.utils import data as db_data -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, load_env -from app.utils.helper import ( - format_date, - generate_code, - is_valid_url, - sanitize_url, -) -from app.utils.qr import generate_qr_with_logo - -load_env() +# from fastapi.exceptions import RequestValidationError +# from starlette.exceptions import HTTPException as StarletteHTTPException +from fastapi.exceptions import HTTPException as FastAPIHTTPException +from fastapi.templating import Jinja2Templates +from app.routes import ui_router +from app.utils import db +from app.utils.cache import cleanup_expired # ----------------------------- -# Lifespan: env + DB connect ONCE +# Background cache cleanup task # ----------------------------- -@asynccontextmanager -async def lifespan(app: FastAPI): - db_data.connect_db() - yield - +from app.utils.config import ( + CACHE_TTL, + SESSION_SECRET, + QR_DIR, +) -app = FastAPI(title="TinyURL", lifespan=lifespan) -app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) -BASE_DIR = Path(__file__).resolve().parent -STATIC_DIR = BASE_DIR / "static" +async def cache_health_check(): + logger = logging.getLogger(__name__) + logger.info("๐Ÿงน Cache cleanup task started") -app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") -templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + interval = max(1, CACHE_TTL // 3) # pure TTL-based + logger.info(f"๐Ÿ•’ Cache cleanup interval set to {interval}s") -def build_short_url(short_code: str, request_host_url: str) -> str: - base_url = DOMAIN.rstrip("/") - return f"{base_url}/{short_code}" + while True: + try: + cleanup_expired() + except Exception as e: + logger.error(f"Cache cleanup error: {e}") + await asyncio.sleep(interval) -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - session = request.session +# ----------------------------- +# Lifespan: env + DB connect ONCE (DB-optional) +# ----------------------------- +@asynccontextmanager +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() + logger.info("๐ŸŸข MongoDB enabled") + else: + logger.warning("๐ŸŸก MongoDB disabled (cache-only mode)") - 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) + # Cache TTL cleanup + cache_task = asyncio.create_task(cache_health_check()) + logger.info("๐Ÿงน Cache TTL cleanup enabled") - qr_image = None - qr_data = None + logger.info("Application startup complete") + yield - 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}" + logger.info("Application shutdown: Cleaning up...") - all_urls = db_data.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( - MAX_RECENT_URLS - ) + # Stop cache task + cache_task.cancel() + try: + await cache_task + except asyncio.CancelledError: + logger.info("๐Ÿงน Cache cleanup task stopped") - 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_data.get_collection() is not None, - }, - ) + # 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() + logger.info("MongoDB client closed") + except Exception as e: + logger.error(f"Error closing MongoDB client: {str(e)}") -@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"), -): - session = request.session - qr_enabled = bool(generate_qr) - original_url = sanitize_url(original_url) - - if not original_url: - session["error"] = "URL cannot be empty." - return RedirectResponse("/", status_code=303) - - if not is_valid_url(original_url): - session["error"] = ( - "Please enter a valid URL (must start with http:// or https://)." - ) - return RedirectResponse("/", status_code=303) + logger.info("Application shutdown complete") - # 1. Try Cache First - short_code: Optional[str] = get_short_from_cache(original_url) - if short_code: - session["info_message"] = "Already shortened before โ€” fetched from cache." - else: - # 2. Try Database - existing = db_data.find_by_original_url(original_url) - # Pull the value and check it in one go - 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) # Cache it for future - session["info_message"] = ( - "Already shortened before โ€” fetched from database." - ) - - # 3. Generate New if still None - if not short_code: - short_code = generate_code() - set_cache_pair(short_code, original_url) - db_data.insert_url(short_code, original_url) - - # --- TYPE GUARD FOR MYPY --- - # At this point, short_code could still technically be Optional[str] - # if generate_code() wasn't strictly typed. We cast or assert. - if not isinstance(short_code, str): - # This acts as a final safety net for production - session["error"] = "Internal server error: Code generation failed." - return RedirectResponse("/", status_code=303) - - # Mypy now knows short_code is strictly 'str' - new_short_url = build_short_url(short_code, str(request.base_url)) - - session.update( - { - "new_short_url": new_short_url, - "qr_enabled": qr_enabled, - "qr_type": qr_type, - "original_url": original_url, - "short_code": short_code, - } - ) +app = FastAPI(title="TinyURL", lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) +templates = Jinja2Templates(directory="app/templates") - return RedirectResponse("/", status_code=303) +# Mount QR static files +BASE_DIR = Path(__file__).resolve().parent +# Mount QR static files +app.mount( + "/static", + StaticFiles(directory=str(BASE_DIR / "static")), + name="static", +) +# Ensure QR directory exists at startup +QR_DIR.mkdir(parents=True, exist_ok=True) +app.mount( + "/qr", + StaticFiles(directory=str(QR_DIR)), + name="qr", +) -@app.get("/recent", response_class=HTMLResponse) -async def recent_urls(request: Request): - recent_urls_list = db_data.get_recent_urls( - MAX_RECENT_URLS - ) or get_recent_from_cache(MAX_RECENT_URLS) +# ----------------------------- +# 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.exception_handler(404) +# async def custom_404_handler(request: Request, exc): +# return templates.TemplateResponse( +# "404.html", +# {"request": request}, +# status_code=404, +# ) + + +@app.exception_handler(FastAPIHTTPException) +async def http_exception_handler(request: Request, exc: FastAPIHTTPException): + + # If it's API/UI route โ†’ return JSON + if request.url.path.startswith("/cache") or request.url.path.startswith("/api"): + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) - 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), - } + # If it's browser route โ†’ return HTML page + if exc.status_code == 404: + return templates.TemplateResponse( + "404.html", + {"request": request}, + status_code=404, ) - return templates.TemplateResponse( - "recent.html", - { - "request": request, - "urls": normalized, - "format_date": format_date, - }, + return JSONResponse( + status_code=exc.status_code, + content={"success": False, "error": exc.detail}, ) -@app.post("/delete/{short_code}") -async def delete_url(request: Request, short_code: str): - db_data.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): - doc = db_data.increment_visit(short_code) - - cached_url = get_from_cache(short_code) - if cached_url: - return RedirectResponse(cached_url) - - if doc: - set_cache_pair(short_code, doc["original_url"]) - return RedirectResponse(doc["original_url"]) - if db_data.get_collection() is None: - return PlainTextResponse("Database is not connected.", status_code=503) - - 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.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 "/" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..ba5f165 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,360 @@ +import os +from datetime import datetime, timezone +from typing import Optional +from app.utils.cache import list_cache_clean, clear_cache +from fastapi import ( + APIRouter, + Form, + Request, + status, + HTTPException, + BackgroundTasks, + Header, + Query, +) +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, + increment_visit_cache, + url_cache, + remove_cache_key, + rev_cache, +) +from app.utils.config import DOMAIN, MAX_RECENT_URLS, CACHE_PURGE_TOKEN, QR_DIR +from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date +from app.utils.qr import generate_qr_with_logo + +# templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates = Jinja2Templates(directory="app/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) + 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 + qr_filename = f"{short_code}.png" + generate_qr_with_logo(qr_data, str(QR_DIR / qr_filename)) + qr_image = f"/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("/history", 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, + "db_available": db.get_collection() is not None, + "get_visit_count_from_cache": increment_visit_cache, + }, + ) + + +@ui_router.get("/cache/list") +def cache_list_ui(): + return list_cache_clean() + + +@ui_router.delete("/cache/purge", response_class=PlainTextResponse) +def cache_purge_ui(x_cache_token: str = Header(..., alias="X-Cache-Token")): + """ + Force delete everything from cache (secured by header) + """ + if x_cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") + + if not url_cache and not rev_cache: + return "No URLs in cache" + + clear_cache() + return "cleared ALL" + + +@ui_router.patch("/cache/remove") +def cache_remove_one_ui( + key: str = Query(..., description="short_code OR original_url"), + x_cache_token: str = Header(..., alias="X-Cache-Token"), +): + # ๐Ÿ” Header security + if x_cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") + + removed = remove_cache_key(key) + + if not removed: + raise HTTPException( + status_code=404, + detail="Key not found in cache.", + ) + + return { + "status": "deleted", + } + + +@ui_router.get("/{short_code}") +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(): + 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_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) + raise HTTPException(status_code=404, detail="Page not found") + + +@ui_router.delete("/history/{short_code}") +def delete_recent_api(short_code: str): + recent = get_recent_from_cache(MAX_RECENT_URLS) or [] + removed_from_cache = False + + for i, item in enumerate(recent): + code = item.get("short_code") or item.get("code") + if code == short_code: + recent.pop(i) # remove from cache + removed_from_cache = True + break + + db_available = db.is_connected() + db_deleted = False + + if db_available: + db_deleted = db.delete_by_short_code(short_code) + + if not removed_from_cache and not db_deleted: + raise HTTPException( + status_code=404, detail=f"short_code '{short_code}' not found" + ) + + return { + "success": True, + "status": "deleted", + "short_code": short_code, + "db_deleted": db_deleted, + "db_available": db_available, + } + + +# ---------------- 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.include_router(api_v1) diff --git a/app/static/css/tiny.css b/app/static/css/tiny.css index db092a6..daf3df1 100644 --- a/app/static/css/tiny.css +++ b/app/static/css/tiny.css @@ -16,64 +16,241 @@ body { background-image: radial-gradient(circle at 50% -20%, #1e1e2e 0%, transparent 50%); } +/* Light theme overrides */ +body.light-theme { + /* background + glass */ + --bg: #f9fafb; + --glass: rgba(0, 0, 0, 0.03); + --glass-border: rgba(0, 0, 0, 0.07); + + /* main card + text */ + --card: #ffffff; + --text-primary: #111827; + --text-secondary: #4b5563; + --text-color: #111827; + + /* accent */ + --accent: #2563eb; + + /* Remove the dark radial gradient */ + background-image: none; +} + +/* Layout */ .main-layout { max-width: 900px; margin: 0 auto; - padding: 6rem 1.5rem; + padding: 6rem 1rem 4rem; display: flex; flex-direction: column; - align-items: center; gap: 2rem; - min-height: 52vh; } -/* --- LARGE HERO INPUT --- */ +.page { + padding-top: 6rem; +} + +.app-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 55px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + box-sizing: border-box; + + background: var(--glass); + border-bottom: 1px solid var(--glass-border); + z-index: 1000; +} + +body.light-theme .app-header { + background: #ffffff; + /* solid background */ + border-bottom: 1px solid #e5e7eb; + /* clear separation */ + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +.header-left, +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.app-logo { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #2563eb, #5ab9ff); + color: #ffffff; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.app-name { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); +} + +.header-nav { + display: flex; + gap: 26px; + margin: 0 auto; +} + +.nav-link, +.nav-link:link, +.nav-link:visited { + text-decoration: none; + color: var(--text-primary); + font-weight: 500; + position: relative; +} + +body.dark-theme .app-header { + background: linear-gradient(180deg, #0b1220, #050b14); +} + +.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; +} + +.nav-link { + position: relative; +} + +.nav-link::after { + content: ""; + position: absolute; + left: 0; + bottom: -4px; + width: 0; + height: 2px; + background: var(--text-primary); + transition: width 0.3s; +} + +.nav-link.active::after { + width: 100%; +} + + +.dark-theme .nav-link.active::after { + background: #f8fafc; +} + +.theme-toggle { + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 8px; + font-weight: 700; + background: var(--glass); + color: var(--text-primary); +} + +.theme-toggle:hover { + color: var(--accent); +} + +@media (max-width: 600px) { + .logo { + font-size: 1.2rem; + } +} + +/* Hero input */ .hero-input-card { width: 100%; background: var(--glass); backdrop-filter: blur(20px); border: 1px solid var(--glass-border); - border-radius: 2.5rem; - padding: 3rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + border-radius: 2rem; + padding: 2rem; } .hero-input-card h1 { - font-size: 3.5rem; + font-size: 2.5rem; font-weight: 800; - letter-spacing: -3px; - margin: 0 0 1.5rem 0; text-align: center; - background: linear-gradient(to right, #fff, #666); + margin-bottom: 1.5rem; + background: linear-gradient(to right, var(--text-primary), var(--text-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .input-wrapper { - position: relative; display: flex; gap: 1rem; + flex-wrap: wrap; } .input-wrapper input { flex: 1; + min-width: 200px; background: rgba(255, 255, 255, 0.05); border: 1px solid var(--glass-border); border-radius: 1.5rem; - padding: 1.5rem 2rem; - color: white; - font-size: 1.2rem; - outline: none; - transition: all 0.3s ease; + padding: 1rem; + font-size: 1rem; + color: var(--text-primary); } -.input-wrapper input:focus { - border-color: var(--accent); - background: rgba(255, 255, 255, 0.08); - box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); +.btn-primary { + padding: 0.8rem 2rem; + border-radius: 1.5rem; + background: var(--accent); + color: #fff; + font-weight: bold; + border: none; + cursor: pointer; + transition: background 0.3s; +} + +.btn-primary:hover { + background: #4f46e5; +} + +.options-row { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; } -/* --- OUTPUT CARD (Immediate Result) --- */ +/* Result card */ .result-card { width: 100%; background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(0, 0, 0, 0)); @@ -81,30 +258,90 @@ body { border-radius: 2rem; padding: 2rem; display: flex; - align-items: center; justify-content: space-between; - animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + align-items: center; + gap: 2rem; +} + +.result-content { + display: flex; + align-items: center; + gap: 1.5rem; } -/* --- SCROLLABLE RECENT TRAY --- */ +.qr-image { + width: 80px; + height: 80px; + border-radius: 1rem; + background: #fff; + padding: 0.5rem; +} + +.ready-label { + font-size: 0.8rem; + text-transform: uppercase; + color: var(--accent); + font-weight: 800; +} + +.short-url a { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + text-decoration: none; +} + +.result-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.btn-copy { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + color: var(--text-primary); + padding: 0.8rem 1.5rem; + border-radius: 1rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-copy:hover { + border-color: var(--accent); + color: var(--accent); +} + +.download-link { + font-size: 0.75rem; + color: var(--text-secondary); + text-decoration: none; +} + +.download-link:hover { + color: var(--accent); +} + +/* Recent tray */ .recent-tray { width: 100%; - margin-top: 4rem; + margin-top: 2rem; } .recent-header { display: flex; justify-content: space-between; margin-bottom: 1rem; - padding: 0 1rem; + padding: 0 0.5rem; + align-items: center; } .scroll-container { display: flex; gap: 1rem; overflow-x: auto; - padding: 1rem; - mask-image: linear-gradient(to right, black 85%, transparent); + padding: 1rem 0; } .scroll-container::-webkit-scrollbar { @@ -112,51 +349,273 @@ body { } .recent-item { - min-width: 250px; + min-width: 220px; background: var(--glass); border: 1px solid var(--glass-border); - padding: 1.5rem; - border-radius: 1.5rem; - font-size: 0.9rem; + padding: 1rem; + border-radius: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; } -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); +.short-code { + color: var(--accent); + font-weight: bold; +} + +.hero { + text-align: center; + margin: 40px 0; +} + +.hero h1 { + font-size: 42px; + font-weight: 700; +} + +/* =============================== + MODERN GLASS RECENT TABLE +================================= */ +/* PAGE CONTAINER */ +.recent-page-container { + width: 100%; + max-width: 1200px; + /* controls table width */ + margin: 0 auto; + /* centers */ + padding: 0 24px; + /* space left & right */ + box-sizing: border-box; +} + +/* Wrapper */ +.recent-table-wrapper { + width: 100%; + /*margin-top: 20px; + margin-bottom: 20px;*/ + overflow-x: auto; +} + +/* =============================== + TABLE BASE +================================= */ + +.recent-table { + width: 100%; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + table-layout: fixed; + min-width: 800px; +} + +/* Header */ +.recent-table thead { + background: var(--glass); +} + +.recent-table th { + padding: 10px 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); + white-space: nowrap; +} + +/* Body cells */ +.recent-table td { + padding: 14px; + font-size: 14px; + color: var(--text-primary); + border-bottom: 1px solid var(--glass-border); + vertical-align: middle; + transition: 0.25s ease; + white-space: nowrap; +} + +/* Row hover */ +.recent-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* =============================== + COLUMN WIDTH CONTROL +================================= */ + +/* # column */ +.recent-table th:nth-child(1), +.recent-table td:nth-child(1) { + width: 45px; + text-align: center; + padding-left: 6px; + padding-right: 6px; +} + +/* Short URL */ +.recent-table th:nth-child(2), +.recent-table td:nth-child(2) { + width: 170px; +} + +/* Original URL (main space owner) */ +.recent-table th:nth-child(3), +.recent-table td:nth-child(3) { + width: 45%; + min-width: 0; +} + +/* Created */ +.recent-table th:nth-child(4), +.recent-table td:nth-child(4) { + width: 170px; +} + +/* Visits */ +.recent-table th:nth-child(5), +.recent-table td:nth-child(5) { + width: 80px; + text-align: center; + font-weight: 700; + color: var(--accent-2); +} + +/* Actions */ +.recent-table th:nth-child(6), +.recent-table td:nth-child(6) { + width: 120px; +} + +/* =============================== + LINKS +================================= */ + +.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 truncate */ +.original-url { + word-break: break-all; +} + +.original-url a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} + +.original-url a:hover { + color: var(--accent); +} + +/* Created time */ +.created-time { + font-size: 13px; + color: var(--muted); + white-space: nowrap; +} + +/* =============================== + ACTION BUTTONS +================================= */ + +.action-col { + display: flex; + gap: 10px; + justify-content: flex-start; +} + +.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; +} + +/* =============================== + DARK MODE +================================= */ + +.dark-theme .recent-table th, +.dark-theme .recent-table td { + color: #e5e7eb; + border-bottom: 1px solid var(--glass-border); +} + +/* Tablet */ +@media (max-width: 1024px) { + .recent-page-container { + padding: 0 18px; } +} - to { - opacity: 1; - transform: translateY(0); +/* Mobile */ +@media (max-width: 768px) { + .recent-page-container { + padding: 0 12px; } } -/* --- LARGE FOOTER --- */ -.big-footer { - background: rgba(255, 255, 255, 0.01); - border-top: 1px solid var(--border); - padding: 6rem 0 3rem 0; - margin-top: 8rem; +/* Small phones */ +@media (max-width: 480px) { + .recent-page-container { + padding: 0 8px; + } +} + +/* Footer */ +footer.big-footer { + background: var(--bg); + border-top: 1px solid var(--glass-border); + padding: 4rem 1rem 2rem; + margin-top: 4rem; } .footer-grid { max-width: 1200px; margin: 0 auto; - padding: 0 1.5rem; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; - gap: 4rem; + gap: 2rem; } .footer-brand h3 { font-size: 1.5rem; - margin: 0 0 1rem 0; - letter-spacing: -1px; + margin-bottom: 1rem; } .footer-brand p { - color: var(--text-dim); + color: var(--text-secondary); line-height: 1.6; max-width: 320px; } @@ -165,8 +624,8 @@ body { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.1em; - margin-bottom: 1.5rem; - color: var(--text); + margin-bottom: 1rem; + color: var(--text-primary); } .footer-col ul { @@ -180,7 +639,7 @@ body { } .footer-col ul li a { - color: var(--text-dim); + color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; @@ -192,65 +651,140 @@ body { .footer-bottom { max-width: 1200px; - margin: 4rem auto 0; - padding: 2rem 1.5rem 0; - border-top: 1px solid var(--border); + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; - color: var(--text-dim); + color: var(--text-secondary); font-size: 0.8rem; } +.footer-meta { + display: flex; + gap: 1rem; +} + +.footer-meta a { + color: inherit; + text-decoration: none; +} + +.footer-meta a:hover { + color: var(--accent); +} + +/* Dark mode footer adjustments */ +body.dark-theme footer.big-footer { + background: #020617 !important; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +body.dark-theme .footer-col h4 { + color: #f3f4f6; +} + +body.dark-theme .footer-col p, +body.dark-theme .footer-col ul li a, +body.dark-theme .footer-bottom { + color: #cbd5e1; +} + +body.dark-theme .footer-col ul li a:hover, +body.dark-theme .footer-bottom a { + color: #a5f3fc; +} + +/* Responsive adjustments */ @media (max-width: 900px) { .footer-grid { grid-template-columns: 1fr 1fr; } } -/* The container for the icon/button */ -.copy-btn { - position: relative; - cursor: pointer; - transition: transform 0.1s ease; -} +@media (max-width: 700px) { + .result-card { + flex-direction: column; + align-items: flex-start; + } -.copy-btn:active { - transform: scale(0.9); -} + .result-actions { + align-items: flex-start; + } -/* The "Smart" Tooltip */ -.copy-btn.copy-success::after { - content: attr(data-tooltip); - position: absolute; - top: -30px; - left: 50%; - transform: translateX(-50%); - background: #333; - color: #fff; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; - animation: fadeInOut 2s forwards; + .recent-item { + min-width: 180px; + } } -@keyframes fadeInOut { - 0% { - opacity: 0; - transform: translate(-50%, 5px); +@media (max-width: 600px) { + .hero-input-card h1 { + font-size: 2rem; } - 15% { - opacity: 1; - transform: translate(-50%, 0); + .short-url a { + font-size: 1.2rem; } - 85% { - opacity: 1; + .footer-grid { + grid-template-columns: 1fr; } - 100% { - opacity: 0; + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; } } + + + + +/* allow wrapping */ +.recent-tray .recent-item .original-url, +.recent-tray .recent-item .original-url a { + display: -webkit-box; + -webkit-box-orient: vertical; + + -webkit-line-clamp: 3; + /* โญ change 2 or 3 lines here */ + line-clamp: 3; + + overflow: hidden; + text-overflow: ellipsis; + + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; +} + +/* IMPORTANT โ€” remove width restriction */ +.recent-tray .recent-item { + min-width: 0; + /* allows shrinking inside flex/grid */ + max-width: 100%; +} + +/* =============================== + VIEW HISTORY COLOR BY THEME +================================= */ + +body.dark-theme .history-link { + color: #ffffff; +} + +body.light-theme .history-link { + color: #000000; +} + +.history-link { + text-decoration: line; + font-weight: 600; + transition: 0.2s ease; +} + +.history-link:hover { + opacity: 0.7; +} \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css index b754687..8dd7671 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,815 +1,1063 @@ -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; +} + +footer { + margin-top: 0; +} + +body.dark-theme, +body.dark-theme .page, +body.dark-theme main, +body.dark-theme section { + background: #0f1720 !important; +} + +/*: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); + --text-primary: var(--text-color); + --text-secondary: var(--text-muted); + --accent: var(--accent-1); +} + +.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; +} + +/* /* =============================== + MODERN UI STYLE FOOTER (VISIT PAGE) +================================= */ + +.app-footer { + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(16px); + border-top: 1px solid var(--glass-border); + padding: 2.5rem 1rem 1.2rem; + /* reduced space */ + margin-top: 40px; +} + +/* Container */ +.footer-container { + max-width: 1100px; + margin: 0 auto; + + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 1.8rem; +} + +/* Footer columns */ +.footer-col h4 { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 16px; + color: var(--text-primary); +} + +.footer-col p { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 10px; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + transition: 0.2s ease; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +/* Footer bottom */ +.footer-bottom { + /* margin: 3rem auto 0; + padding-top: 20px; + + display: flex; + justify-content: space-between; + align-items: center; + + border-top: 1px solid var(--glass-border); + font-size: 14px; + color: var(--text-secondary); */ + + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + color: var(--text-secondary); + font-size: 0.8rem; + +} + +/* Version + GitHub area */ +.footer-bottom a { + color: var(--accent); + text-decoration: none; + transition: 0.2s ease; +} + +.footer-bottom a:hover { + opacity: 0.8; +} + +/* =============================== + DARK MODE SUPPORT +================================= */ + +.dark-theme .app-footer { + background: #0a0a0c; + backdrop-filter: blur(16px); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.dark-theme .footer-col h4 { + color: #f3f4f6; +} + +.dark-theme .footer-col p, +.dark-theme .footer-col ul li a, +.dark-theme .footer-bottom { + color: #cbd5e1; +} + +.dark-theme .footer-col ul li a:hover, +.dark-theme .footer-bottom a { + color: var(--accent-2); +} + +/* =============================== + MOBILE RESPONSIVE +================================= */ + +@media (max-width: 900px) { + .footer-container { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 600px) { + .footer-container { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 10px; + text-align: center; + } +} + +/* REMOVE white line above footer in dark mode */ +footer { + margin-top: 0 !important; +} + +*/ +/* ===================================== + BIG FOOTER STYLE (FOR FIRST PAGE) + Using existing class names +===================================== */ + +/*Footer wrapper +.app-footer { + background: rgba(255, 255, 255, 0.01); + border-top: 1px solid var(--glass-border); + padding: 4rem 1rem 2rem; + margin-top: 4rem; +} + +/* Grid container */ +.footer-container { + max-width: 1200px; + margin: 0 auto; + + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; +} + +/* Brand column (first column) */ +.footer-container>div:first-child h4 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-container>div:first-child p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +.footer-brand h3 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-brand p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +/* Other footer columns */ +.footer-col h4 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 0.8rem; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s ease; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +/* Bottom row */ +.footer-bottom { + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + + color: var(--text-secondary); + font-size: 0.8rem; +} + +/* Footer links inside bottom */ +.footer-bottom a { + color: inherit; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-bottom a:hover { + color: var(--accent); +} + +/* Responsive */ +@media (max-width: 900px) { + .footer-container { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 600px) { + .footer-container { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +*/ + +/* Footer */ +.big-footer { + background: rgba(255, 255, 255, 0.01); + border-top: 1px solid var(--glass-border); + padding: 4rem 1rem 2rem; + margin-top: 4rem; +} + +.footer-grid { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; +} + +.footer-brand h3 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-brand p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +.footer-col h4 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 0.8rem; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +.footer-bottom { + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.footer-meta { + display: flex; + gap: 1rem; +} + +.footer-meta a { + color: inherit; + text-decoration: none; +} + +.footer-meta a:hover { + color: var(--accent); +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .footer-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 700px) { + .result-card { + flex-direction: column; + align-items: flex-start; + } + + .result-actions { + align-items: flex-start; + } + + .recent-item { + min-width: 180px; + } +} + +@media (max-width: 600px) { + .hero-input-card h1 { + font-size: 2rem; + } + + .short-url a { + font-size: 1.2rem; + } + + .footer-grid { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +/*=============================== + 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/404.html b/app/templates/404.html new file mode 100644 index 0000000..0825534 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,51 @@ +{% block content %} + + + + + Page Not Found + + + + +
+

๐Ÿšซ Page not found

+

The page you are looking for does not exist.

+ + + โ† Go Back + +
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/coming-soon.html b/app/templates/coming-soon.html deleted file mode 100644 index 1a5e5eb..0000000 --- a/app/templates/coming-soon.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - Coming-soon - - - - - - -
-
- - tiny URL -
- - -
- -
-
- - -
-
-
๐Ÿšง
-

Coming Soon

-

- This feature is under active development. -

- - โ† Back to Home -
-
- - - - - \ No newline at end of file diff --git a/app/templates/footer.html b/app/templates/footer.html new file mode 100644 index 0000000..18064fc --- /dev/null +++ b/app/templates/footer.html @@ -0,0 +1,41 @@ + \ No newline at end of file diff --git a/app/templates/header.html b/app/templates/header.html new file mode 100644 index 0000000..0fa001c --- /dev/null +++ b/app/templates/header.html @@ -0,0 +1,28 @@ +
+
+ + RZRO.link +
+ +
+ +
+
+ \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index eeadff1..a4a9a77 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,146 +1,61 @@ -{% extends "layout.html" %} - -{% block content %} +{% extends "layout.html" %} {% block content %}
-

tiny URL

+

Shorten Your Links

-
- - -
-
- - Analytics Enabled -
+
+
Analytics Enabled
{% if new_short_url %}
-
+
{% if qr_image %} - + {% endif %} -
- - - {% if qr_image %} - - Download QR - - {% endif %} -
-
- {% endif %} - -
+
{% if qr_image %} Download QR {% endif + %}
+
{% endif %}
-

Recently Shortened

- View History โ†’ -
-
- {% for url in urls %} -
-
/{{ url.short_code }}
-
- {{ url.original_url }}
-
- {% endfor %} +

Recently Shortened

+ View History โ†’
+
{% for url in urls %}
+
/{{ url.short_code }}
+
{{ url.original_url }}
+
{% endfor %}
- - diff --git a/app/templates/layout.html b/app/templates/layout.html index aeb3e5b..23d8a6f 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -15,7 +15,7 @@ - + {% block head_extra %}{% endblock %} @@ -23,14 +23,29 @@
+ {% include "header.html" %} + {% block content %}{% endblock %} + {% include "footer.html" %} + diff --git a/app/templates/recent.html b/app/templates/recent.html index 26c5d69..fbe52c7 100644 --- a/app/templates/recent.html +++ b/app/templates/recent.html @@ -1,210 +1,77 @@ - - - - - - - Recent URLs - - - - - - -
-
- - tiny URL -
- - -
- -
-
- - - -
-
- -
- - - -
-

Recent Shortened URLs

-
- - -
- -
- - - - - - - - - - - - - - {% for url in urls %} - - - - - - - - - - - - - - - {% else %} - - - - {% endfor %} - -
#Short URLOriginal URLCreatedVisitsActions
{{ loop.index }} - - {{ request.host_url }}{{ url.short_code }} - - - - {{ url.original_url }} - - - {{ format_date(url.created_at) }} - {{ url.visit_count }} - - โ†— - - - - ๐Ÿ—‘ - -
- No URLs found. -
-
- -
- -
- - - - - - - \ No newline at end of file +{% extends "layout.html" %} +{% block title %}Recent URLs{% endblock %} +{% block content %} +
+
+
+

Recent Shortened URLs

+
+
+
+ + + + + + + + + + + + {% for url in urls %} + + + + + + + + {% else %} + + {% endfor %} +
#Short URLOriginal URLCreatedVisitsActions
{{ loop.index }} {{ + request.host_url }}{{ url.short_code }} {{ + url.original_url }} {{ format_date(url.created_at) }} {{ url.visit_count }} โ†— ๐Ÿ—‘
No URLs found.
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/app/utils/cache.py b/app/utils/cache.py index a6f1c6a..a5e66ee 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -1,31 +1,95 @@ import time from typing import TypedDict - +from datetime import datetime +from zoneinfo import ZoneInfo from app.utils.config import CACHE_TTL, MAX_RECENT_URLS class UrlCacheItem(TypedDict): url: str expires_at: float + visit_count: int class RevCacheItem(TypedDict): short_code: str expires_at: float + created_at: float last_accessed: float +class RecentItem(TypedDict): + short_code: str + original_url: str + created_at: float + + +# ----------------------- +# Performance caches (TTL) +# ----------------------- + # short_code -> original_url url_cache: dict[str, UrlCacheItem] = {} # 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() +# ----------------------- +# Core cache operations +# ----------------------- + + +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 + + url_cache[short_code] = { + "url": original_url, + "expires_at": expires_at, + "visit_count": 0, + } + + rev_cache[original_url] = { + "short_code": short_code, + "expires_at": expires_at, + "created_at": now, + "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 + + def get_from_cache(short_code: str) -> str | None: data = url_cache.get(short_code) @@ -34,6 +98,7 @@ def get_from_cache(short_code: str) -> str | None: if data["expires_at"] < _now(): url_cache.pop(short_code, None) + _remove_recent_if_exists(short_code) return None return data["url"] @@ -49,65 +114,75 @@ def get_short_from_cache(original_url: str) -> str | None: 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[RecentItem]: 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, - } + 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"], + } + ) -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.sort(key=lambda x: x["created_at"], reverse=True) + return valid_items[:limit] def cleanup_expired() -> None: - """ - Optional: Manually remove expired cache entries. - Can be called periodically (cron/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 key in expired_short_codes: - url_cache.pop(key, None) - expired_urls = [ - key for key, value in rev_cache.items() if value["expires_at"] < now + for short_code in expired_short_codes: + url_cache.pop(short_code, None) + _remove_recent_if_exists(short_code) + + expired_original_urls = [ + original_url + for original_url, data in rev_cache.items() + if data["expires_at"] < now ] - for key in expired_urls: - rev_cache.pop(key, None) + + for original_url in expired_original_urls: + rev_cache.pop(original_url, None) + + +def clear_cache() -> None: + url_cache.clear() + rev_cache.clear() + + +def _remove_recent_if_exists(short_code: str) -> None: + to_delete = None + + for original_url, data in rev_cache.items(): + if data["short_code"] == short_code: + to_delete = original_url + break + + if to_delete: + rev_cache.pop(to_delete, None) # ----------------------- -# Recent URLs (derived from rev_cache) +# UI helpers # ----------------------- -def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]: +def list_cache_clean() -> dict: """ - Returns recent URLs based on cache activity (no duplicates, TTL-aware). - Shape matches DB docs. + Clean UI-friendly cache view (TTL-aware, no debug noise). """ now = _now() @@ -115,13 +190,39 @@ def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[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 ] - items.sort( - key=lambda x: rev_cache[x["original_url"]]["last_accessed"], reverse=True - ) + return { + "count": len(items), + "items": items, + "MAX_RECENT_URLS": MAX_RECENT_URLS, + "CACHE_TTL": CACHE_TTL, + } + - return items[:limit] +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 c29ffec..30b41a3 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,25 +1,14 @@ import os - -from dotenv import load_dotenv - - -def load_env(): - env = os.getenv("ENV", "development") - file_map = { - "production": ".env", - "local": ".env.local", - "development": ".env.development", - } - load_dotenv(file_map.get(env, ".env.development"), override=True) - print(f"Environment selected: {env}") - print(f"MODE value: {os.getenv('MODE')}") - +from pathlib import Path # ------------------------- # Helpers # ------------------------- -def _get_bool(key: str, default: bool) -> bool: - return os.getenv(key, str(default)).lower() in ("1", "true", "yes", "on") + + +from app.utils.config_env import load_env # noqa: F401 + +load_env() def _get_int(key: str, default: int) -> int: @@ -54,13 +43,28 @@ def _get_int(key: str, default: int) -> int: MONGO_DB_NAME = "tiny_url" MONGO_COLLECTION = os.getenv("MONGO_COLLECTION", "urls") +# Connection timeouts (in milliseconds) +MONGO_TIMEOUT_MS = _get_int("MONGO_TIMEOUT_MS", 10000) +MONGO_SOCKET_TIMEOUT_MS = _get_int("MONGO_SOCKET_TIMEOUT_MS", 20000) + +# Connection pool settings +MONGO_MIN_POOL_SIZE = _get_int("MONGO_MIN_POOL_SIZE", 5) +MONGO_MAX_POOL_SIZE = _get_int("MONGO_MAX_POOL_SIZE", 50) + +# Retry configuration +MONGO_MAX_RETRIES = _get_int("MONGO_MAX_RETRIES", 10) +MONGO_INITIAL_RETRY_DELAY = 1.0 +MONGO_MAX_RETRY_DELAY = 30.0 + +# Health check interval (in seconds) +HEALTH_CHECK_INTERVAL_SECONDS = _get_int("HEALTH_CHECK_INTERVAL_SECONDS", 30) + # ------------------------- # Cache (constants) # ------------------------- USE_CACHE = True CACHE_TTL = 900 # 15 minutes -MAX_CACHE_SIZE = 10_000 MAX_RECENT_URLS = 20 # ------------------------- @@ -68,8 +72,18 @@ 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) # ------------------------- SHORT_CODE_LENGTH = 6 MAX_URL_LENGTH = 2048 + +# for making the qr constant +# Base project paths +BASE_DIR = Path(__file__).resolve().parent.parent # app/ +PROJECT_ROOT = BASE_DIR.parent # project root +# QR directory constant +QR_DIR = PROJECT_ROOT / "assets" / "images" / "qr" diff --git a/app/utils/config_env.py b/app/utils/config_env.py new file mode 100644 index 0000000..049232d --- /dev/null +++ b/app/utils/config_env.py @@ -0,0 +1,15 @@ +import os + +from dotenv import load_dotenv + + +def load_env(): + env = os.getenv("ENV", "development") + file_map = { + "production": ".env", + "local": ".env.local", + "development": ".env.development", + } + load_dotenv(file_map.get(env, ".env.development"), override=True) + print(f"Environment selected: {env}") + print(f"MODE value: {os.getenv('MODE')}") diff --git a/app/utils/data.py b/app/utils/data.py deleted file mode 100644 index 50c6f6e..0000000 --- a/app/utils/data.py +++ /dev/null @@ -1,107 +0,0 @@ -from typing import Any, Optional - -try: - from pymongo import MongoClient - from pymongo.errors import PyMongoError - - MONGO_INSTALLED = True -except ImportError: - MongoClient: Any = None # type: ignore - Collection: Any # type: ignore - PyMongoError = Exception # type: ignore - MONGO_INSTALLED = False - -from app.utils.config import MONGO_COLLECTION, MONGO_DB_NAME, MONGO_URI - -client: Any = None -db: Any = None -collection: Any = None - - -def connect_db() -> bool: - global client, db, collection - - if not MONGO_INSTALLED: - return False - - try: - # Create instance - new_client: Any = MongoClient(MONGO_URI, serverSelectionTimeoutMS=2000) - new_client.admin.command("ping") - client = new_client - db = new_client[MONGO_DB_NAME] - collection = db[MONGO_COLLECTION] - return True - - except Exception: - client = db = collection = None - return False - - return False - - -def get_collection() -> Optional[dict[str, Any]]: - return collection - - -# ------------------------ -# DB Operations -# ------------------------ - - -def find_by_original_url(original_url: str) -> Optional[dict]: - if collection is None: - return None - try: - return collection.find_one({"original_url": original_url}) - except PyMongoError: - return None - - -def insert_url(short_code: str, original_url: str) -> bool: - if collection is None: - return False - try: - collection.insert_one( - { - "short_code": short_code, - "original_url": original_url, - "created_at": __import__("datetime").datetime.utcnow(), - "visit_count": 0, - } - ) - return True - except PyMongoError: - return False - - -def delete_by_short_code(short_code: str) -> bool: - if collection is None: - return False - try: - collection.delete_one({"short_code": short_code}) - return True - except PyMongoError: - return False - - -def get_recent_urls(limit: int = 10) -> list[dict]: - if collection is None: - return [] - try: - return list(collection.find().sort("created_at", -1).limit(limit)) - except PyMongoError: - return [] - - -def increment_visit(short_code: str) -> Optional[dict]: - if collection is None: - return None - try: - return collection.find_one_and_update( - {"short_code": short_code}, - {"$inc": {"visit_count": 1}}, - return_document=True, - ) - except PyMongoError: - return None diff --git a/app/utils/db.py b/app/utils/db.py new file mode 100644 index 0000000..ef5bba1 --- /dev/null +++ b/app/utils/db.py @@ -0,0 +1,248 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any, Optional + +try: + from pymongo import MongoClient + from pymongo.errors import PyMongoError + + MONGO_INSTALLED = True +except ImportError: + MongoClient: Any = None # type: ignore + PyMongoError = Exception # type: ignore + MONGO_INSTALLED = False + +from app.utils.config import MONGO_COLLECTION, MONGO_DB_NAME, MONGO_URI + +# Configure logger +logger = logging.getLogger(__name__) + +# MongoDB client and collection +client: Any = None +db: Any = None +collection: Any = None + +# Connection state management +connection_state: str = "DISCONNECTED" # DISCONNECTED, CONNECTING, CONNECTED, FAILED +last_connection_attempt: Optional[datetime] = None +connection_error: Optional[str] = None +health_check_task: Any = None + + +def connect_db(max_retries: int = 1) -> 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 + + if not MONGO_INSTALLED: + logger.error("PyMongo is not installed") + connection_state = "FAILED" + 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_TIMEOUT_MS, + MONGO_SOCKET_TIMEOUT_MS, + MONGO_MIN_POOL_SIZE, + MONGO_MAX_POOL_SIZE, + ) + + connection_state = "CONNECTING" + last_connection_attempt = datetime.utcnow() + + 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, + ) + + new_client.admin.command("ping") + + client = new_client + db = new_client[MONGO_DB_NAME] + collection = db[MONGO_COLLECTION] + + 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 { + "state": connection_state, + "last_attempt": ( + last_connection_attempt.isoformat() if last_connection_attempt else None + ), + "error": connection_error, + "connected": is_connected(), + } + + +# ------------------------ +# DB Operations (NO-OP SAFE) +# ------------------------ + + +def find_by_original_url(original_url: str) -> Optional[dict]: + if not is_connected(): + logger.warning("Database not connected, cannot find URL") + return None + try: + return collection.find_one({"original_url": original_url}) + except PyMongoError as 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(): + return False + try: + collection.insert_one( + { + "short_code": short_code, + "original_url": original_url, + "created_at": datetime.utcnow(), + "visit_count": 0, + } + ) + return True + except PyMongoError as 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(): + return False + try: + result = collection.delete_one({"short_code": short_code}) + return result.deleted_count > 0 + except PyMongoError as 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(): + return [] + try: + return list(collection.find().sort("created_at", -1).limit(limit)) + except PyMongoError as e: + logger.error(f"DB error (get_recent_urls): {e}") + _mark_failed(e) + return [] + + +def increment_visit(short_code: str) -> Optional[dict]: + if not is_connected(): + logger.warning("Database not connected, cannot increment visit") + return None + try: + return collection.find_one_and_update( + {"short_code": short_code}, + {"$inc": {"visit_count": 1}}, + return_document=True, + ) + except PyMongoError as 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 (Background reconnect) +# ------------------------ + + +async def health_check_loop() -> None: + from app.utils.config import HEALTH_CHECK_INTERVAL_SECONDS + + logger.info("๐Ÿซ€ DB health check started") + + try: + while True: + await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS) + + if not is_connected(): + logger.info("๐Ÿ” DB disconnected. Retrying connection...") + connect_db() + continue + + try: + if client: + client.admin.command("ping") + except Exception as e: + logger.error(f"โŒ Health check failed: {e}") + _mark_failed(e) + + except asyncio.CancelledError: + logger.info("Health check loop cancelled") + raise + + +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 + + +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() + try: + await health_check_task + except asyncio.CancelledError: + logger.info("Health check task stopped") + health_check_task = None 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" 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" + } + } } diff --git a/group_shorten.ps1 b/group_shorten.ps1 deleted file mode 100644 index 3e52c10..0000000 --- a/group_shorten.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -<# -========================================================= - PowerShell Script: group_shorten.ps1 ---------------------------------------------------------- - Description: - Bulk URL shortener client for Tiny API. - Reads a JSON file containing multiple URLs and sends - them one-by-one to the /api/shorten endpoint. - ---------------------------------------------------------- - HOW TO RUN (Windows PowerShell TERMINAL) ---------------------------------------------------------- - - 1. Open PowerShell as Administrator - - 2. Allow script execution (one-time): - Set-ExecutionPolicy RemoteSigned - - 3. Navigate to project root: - cd path\to\your\project - - 4. Run with JSON file: - .\group_shorten.ps1 -file ".\request\mixed_urls.json" - - 5. Or run without argument (interactive): - .\group_shorten.ps1 - - ---------------------------------------------------------- - HOW TO RUN (Windows PowerShell ISE) ---------------------------------------------------------- - - Method 1: Run with parameter - -------------------------------- - 1. Open Windows PowerShell ISE - 2. Open this script file (group_shorten.ps1) - 3. In the top menu, click: - File โ†’ New PowerShell Tab - 4. In the console pane (bottom), run: - .\group_shorten.ps1 -file ".\request\mixed_urls.json" - - Method 2: Run interactively - -------------------------------- - 1. Open Windows PowerShell ISE - 2. Open this script file - 3. Press the green โ–ถ๏ธ Run Script button - 4. When prompted, enter the JSON file path - ---------------------------------------------------------- - Expected JSON input format: - [ - { "url": "https://example.com" }, - { "url": "https://google.com" } - ] -========================================================= -#> - -param ( - [string]$file -) - -# Ask for file path if not provided -if (-not $file) { - $file = Read-Host "Enter path to JSON file" -} - -# Validate file -if (-not (Test-Path $file)) { - Write-Host "โŒ File not found: $file" -ForegroundColor Red - exit 1 -} - -# Load JSON -try { - $data = Get-Content $file -Raw | ConvertFrom-Json -} -catch { - Write-Host "โŒ Invalid JSON file" -ForegroundColor Red - exit 1 -} - -$apiUrl = "http://127.0.0.1:8001/api/v1/shorten" - -Write-Host "`n๐Ÿš€ Processing URLs..." -ForegroundColor Cyan - -foreach ($item in $data) { - - # Support string + object format - $url = if ($item -is [string]) { $item } else { $item.url } - - if (-not $url) { - Write-Host "โš ๏ธ Empty URL, skipped" -ForegroundColor Yellow - continue - } - - # -------- LOCAL URL VALIDATION -------- - if ($url -notmatch '^[a-zA-Z]+://') { - Write-Host "โŒ ERROR:" $url "- Missing protocol (http/https)" -ForegroundColor yellow - continue - } - - if ($url -notmatch '^https?://') { - Write-Host "โŒ ERROR:" $url "- Unsupported protocol" -ForegroundColor yellow - continue - } - - if ($url -match 'https?:/[^/]') { - Write-Host "โŒ ERROR:" $url "- Malformed URL (missing /)" -ForegroundColor yellow - continue - } - - if ($url -match '\.\.') { - Write-Host "โŒ ERROR:" $url "- Invalid domain format" -ForegroundColor yellow - continue - } - - # -------- API REQUEST -------- - $body = @{ url = $url } | ConvertTo-Json - - try { - $response = Invoke-RestMethod ` - -Uri $apiUrl ` - -Method POST ` - -ContentType "application/json" ` - -Body $body - - Write-Host "โœ… SUCCESS:" $url "โ†’" $response.short_code -ForegroundColor Green - } - catch { - Write-Host "โŒ ERROR:" $url "- Rejected by API" -ForegroundColor Yellow - } -} - -Write-Host "`n๐ŸŽ‰ Done!" -ForegroundColor Cyan diff --git a/poetry.lock b/poetry.lock index 0d9aade..6c6d90a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,19 +44,6 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_full_version < \"3.11.3\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - [[package]] name = "backports-tarfile" version = "1.2.0" @@ -169,14 +156,14 @@ redis = ["redis (>=2.10.5)"] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] @@ -616,14 +603,14 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand [[package]] name = "filelock" -version = "3.21.2" +version = "3.25.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560"}, - {file = "filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708"}, + {file = "filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047"}, + {file = "filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3"}, ] [[package]] @@ -863,103 +850,103 @@ type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] [[package]] name = "librt" -version = "0.8.0" +version = "0.8.1" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "librt-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db63cf3586a24241e89ca1ce0b56baaec9d371a328bd186c529b27c914c9a1ef"}, - {file = "librt-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba9d9e60651615bc614be5e21a82cdb7b1769a029369cf4b4d861e4f19686fb6"}, - {file = "librt-0.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb4b3ad543084ed79f186741470b251b9d269cd8b03556f15a8d1a99a64b7de5"}, - {file = "librt-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d2720335020219197380ccfa5c895f079ac364b4c429e96952cd6509934d8eb"}, - {file = "librt-0.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726305d3e53419d27fc8cdfcd3f9571f0ceae22fa6b5ea1b3662c2e538f833e"}, - {file = "librt-0.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3d107f603b5ee7a79b6aa6f166551b99b32fb4a5303c4dfcb4222fc6a0335e"}, - {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41064a0c07b4cc7a81355ccc305cb097d6027002209ffca51306e65ee8293630"}, - {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c6e4c10761ddbc0d67d2f6e2753daf99908db85d8b901729bf2bf5eaa60e0567"}, - {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba581acad5ac8f33e2ff1746e8a57e001b47c6721873121bf8bbcf7ba8bd3aa4"}, - {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bdab762e2c0b48bab76f1a08acb3f4c77afd2123bedac59446aeaaeed3d086cf"}, - {file = "librt-0.8.0-cp310-cp310-win32.whl", hash = "sha256:6a3146c63220d814c4a2c7d6a1eacc8d5c14aed0ff85115c1dfea868080cd18f"}, - {file = "librt-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:bbebd2bba5c6ae02907df49150e55870fdd7440d727b6192c46b6f754723dde9"}, - {file = "librt-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ce33a9778e294507f3a0e3468eccb6a698b5166df7db85661543eca1cfc5369"}, - {file = "librt-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8070aa3368559de81061ef752770d03ca1f5fc9467d4d512d405bd0483bfffe6"}, - {file = "librt-0.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:20f73d4fecba969efc15cdefd030e382502d56bb6f1fc66b580cce582836c9fa"}, - {file = "librt-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a512c88900bdb1d448882f5623a0b1ad27ba81a9bd75dacfe17080b72272ca1f"}, - {file = "librt-0.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:015e2dde6e096d27c10238bf9f6492ba6c65822dfb69d2bf74c41a8e88b7ddef"}, - {file = "librt-0.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c25a131013eadd3c600686a0c0333eb2896483cbc7f65baa6a7ee761017aef9"}, - {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:21b14464bee0b604d80a638cf1ee3148d84ca4cc163dcdcecb46060c1b3605e4"}, - {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:05a3dd3f116747f7e1a2b475ccdc6fb637fd4987126d109e03013a79d40bf9e6"}, - {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fa37f99bff354ff191c6bcdffbc9d7cdd4fc37faccfc9be0ef3a4fd5613977da"}, - {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1566dbb9d1eb0987264c9b9460d212e809ba908d2f4a3999383a84d765f2f3f1"}, - {file = "librt-0.8.0-cp311-cp311-win32.whl", hash = "sha256:70defb797c4d5402166787a6b3c66dfb3fa7f93d118c0509ffafa35a392f4258"}, - {file = "librt-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:db953b675079884ffda33d1dca7189fb961b6d372153750beb81880384300817"}, - {file = "librt-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:75d1a8cab20b2043f03f7aab730551e9e440adc034d776f15f6f8d582b0a5ad4"}, - {file = "librt-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17269dd2745dbe8e42475acb28e419ad92dfa38214224b1b01020b8cac70b645"}, - {file = "librt-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4617cef654fca552f00ce5ffdf4f4b68770f18950e4246ce94629b789b92467"}, - {file = "librt-0.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5cb11061a736a9db45e3c1293cfcb1e3caf205912dfa085734ba750f2197ff9a"}, - {file = "librt-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb00bd71b448f16749909b08a0ff16f58b079e2261c2e1000f2bbb2a4f0a45"}, - {file = "librt-0.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95a719a049f0eefaf1952673223cf00d442952273cbd20cf2ed7ec423a0ef58d"}, - {file = "librt-0.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bd32add59b58fba3439d48d6f36ac695830388e3da3e92e4fc26d2d02670d19c"}, - {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f764b2424cb04524ff7a486b9c391e93f93dc1bd8305b2136d25e582e99aa2f"}, - {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f04ca50e847abc486fa8f4107250566441e693779a5374ba211e96e238f298b9"}, - {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9ab3a3475a55b89b87ffd7e6665838e8458e0b596c22e0177e0f961434ec474a"}, - {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e36a8da17134ffc29373775d88c04832f9ecfab1880470661813e6c7991ef79"}, - {file = "librt-0.8.0-cp312-cp312-win32.whl", hash = "sha256:4eb5e06ebcc668677ed6389164f52f13f71737fc8be471101fa8b4ce77baeb0c"}, - {file = "librt-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a33335eb59921e77c9acc05d0e654e4e32e45b014a4d61517897c11591094f8"}, - {file = "librt-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:24a01c13a2a9bdad20997a4443ebe6e329df063d1978bbe2ebbf637878a46d1e"}, - {file = "librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1"}, - {file = "librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf"}, - {file = "librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8"}, - {file = "librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad"}, - {file = "librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01"}, - {file = "librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada"}, - {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae"}, - {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d"}, - {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3"}, - {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b"}, - {file = "librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935"}, - {file = "librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab"}, - {file = "librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2"}, - {file = "librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda"}, - {file = "librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556"}, - {file = "librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06"}, - {file = "librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376"}, - {file = "librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816"}, - {file = "librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e"}, - {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52"}, - {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da"}, - {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab"}, - {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3"}, - {file = "librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a"}, - {file = "librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7"}, - {file = "librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4"}, - {file = "librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb"}, - {file = "librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5"}, - {file = "librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c"}, - {file = "librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546"}, - {file = "librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944"}, - {file = "librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e"}, - {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61"}, - {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05"}, - {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25"}, - {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c"}, - {file = "librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447"}, - {file = "librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9"}, - {file = "librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc"}, - {file = "librt-0.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b705f85311ee76acec5ee70806990a51f0deb519ea0c29c1d1652d79127604d"}, - {file = "librt-0.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7ce0a8cb67e702dcb06342b2aaaa3da9fb0ddc670417879adfa088b44cf7b3b6"}, - {file = "librt-0.8.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aaadec87f45a3612b6818d1db5fbfe93630669b7ee5d6bdb6427ae08a1aa2141"}, - {file = "librt-0.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56901f1eec031396f230db71c59a01d450715cbbef9856bf636726994331195d"}, - {file = "librt-0.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b055bb3abaf69abed25743d8fc1ab691e4f51a912ee0a6f9a6c84f4bbddb283d"}, - {file = "librt-0.8.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ef3bd856373cf8e7382402731f43bfe978a8613b4039e49e166e1e0dc590216"}, - {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e0ffe88ebb5962f8fb0ddcbaaff30f1ea06a79501069310e1e030eafb1ad787"}, - {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82e61cd1c563745ad495387c3b65806bfd453badb4adbc019df3389dddee1bf6"}, - {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:667e2513cf69bfd1e1ed9a00d6c736d5108714ec071192afb737987955888a25"}, - {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6b6caff69e25d80c269b1952be8493b4d94ef745f438fa619d7931066bdd26de"}, - {file = "librt-0.8.0-cp39-cp39-win32.whl", hash = "sha256:02a9fe85410cc9bef045e7cb7fd26fdde6669e6d173f99df659aa7f6335961e9"}, - {file = "librt-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:de076eaba208d16efb5962f99539867f8e2c73480988cb513fcf1b5dbb0c9dcf"}, - {file = "librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b"}, + {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, + {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, + {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, + {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, + {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, + {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, + {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, + {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, + {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, + {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, + {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, + {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, + {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, + {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, + {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, + {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, + {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, + {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, + {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, + {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, + {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, + {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, + {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, + {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, + {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, + {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, + {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, + {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, + {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, + {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, + {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, + {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, + {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, + {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, + {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, + {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, + {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, + {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, + {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, + {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, + {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, + {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, + {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, ] [[package]] @@ -1288,38 +1275,39 @@ files = [ [[package]] name = "nh3" -version = "0.3.2" +version = "0.3.3" description = "Python binding to Ammonia HTML sanitizer Rust crate" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d"}, - {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130"}, - {file = "nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99"}, - {file = "nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868"}, - {file = "nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93"}, - {file = "nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13"}, - {file = "nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80"}, - {file = "nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7"}, - {file = "nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0"}, - {file = "nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6"}, - {file = "nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b"}, - {file = "nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe"}, - {file = "nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104"}, - {file = "nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376"}, + {file = "nh3-0.3.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc"}, + {file = "nh3-0.3.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc"}, + {file = "nh3-0.3.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189"}, + {file = "nh3-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d"}, + {file = "nh3-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81"}, + {file = "nh3-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493"}, + {file = "nh3-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca"}, + {file = "nh3-0.3.3-cp314-cp314t-win32.whl", hash = "sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8"}, + {file = "nh3-0.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808"}, + {file = "nh3-0.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3"}, + {file = "nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691"}, + {file = "nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b"}, + {file = "nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2"}, + {file = "nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489"}, + {file = "nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462"}, + {file = "nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181"}, + {file = "nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37"}, + {file = "nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0"}, + {file = "nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2"}, + {file = "nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee"}, ] [[package]] @@ -1559,14 +1547,14 @@ testing = ["aboutcode-toolkit (>=6.0.0)", "black", "pytest (>=6,!=7.0.0)", "pyte [[package]] name = "platformdirs" -version = "4.7.0" +version = "4.9.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6"}, - {file = "platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36"}, + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, ] [[package]] @@ -1903,14 +1891,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, - {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -2036,27 +2024,6 @@ Pygments = ">=2.5.1" [package.extras] md = ["cmarkgfm (>=0.8.0)"] -[[package]] -name = "redis" -version = "7.1.1" -description = "Python client for Redis database and key-value store" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a"}, - {file = "redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} - -[package.extras] -circuit-breaker = ["pybreaker (>=1.4.0)"] -hiredis = ["hiredis (>=3.2.0)"] -jwt = ["pyjwt (>=2.9.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] - [[package]] name = "requests" version = "2.32.5" @@ -2111,14 +2078,14 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, ] [package.dependencies] @@ -2130,30 +2097,30 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.15.1" +version = "0.15.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a"}, - {file = "ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602"}, - {file = "ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2"}, - {file = "ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61"}, - {file = "ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f"}, - {file = "ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098"}, - {file = "ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336"}, - {file = "ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416"}, - {file = "ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f"}, + {file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"}, + {file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"}, + {file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"}, + {file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"}, + {file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"}, + {file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"}, + {file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"}, + {file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"}, + {file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"}, + {file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"}, + {file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"}, + {file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"}, ] [[package]] @@ -2350,6 +2317,18 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2445,4 +2424,4 @@ mongodb = ["pymongo"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "349ea21b64217dca053564867ff9f7cf5375fe7c8dee163a5689d9f3a25fd371" +content-hash = "3ee41e53f15c4a18956ff1729e5879e1dd88f87cc9f7ad07a4fd092058a9f910" diff --git a/pyproject.toml b/pyproject.toml index 413922a..df48a19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tiny" -version = "1.0.1" +version = "1.0.3" description = "A package for URL Shortener with QR Code generation" authors = [ { name = "recursivezero", email = "152776938+recursivezero@users.noreply.github.com" }, @@ -22,7 +22,6 @@ dependencies = [ "python-dotenv>=1.0.1", "qrcode>=8.2", "pillow>=10.4.0", - "redis>=7.1.0,<8.0.0", "uvicorn>=0.40.0,<0.41.0", "fastapi>=0.128.0,<0.129.0", "pydantic>=2.12.5,<3.0.0", @@ -30,6 +29,7 @@ dependencies = [ "itsdangerous>=2.2.0,<3.0.0", "python-multipart>=0.0.22,<0.0.23", "jinja2>=3.1.2", + "tzdata (>=2025.3,<2026.0)", ] [project.optional-dependencies] mongodb = ["pymongo>=4.16.0,<5.0.0"] diff --git a/requirements.txt b/requirements.txt index bf81eea..a420fad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ anyio==4.12.1 ; python_version >= "3.10" and python_version < "3.13" async-timeout==5.0.1 ; python_version >= "3.10" and python_full_version < "3.11.3" click==8.3.1 ; python_version >= "3.10" and python_version < "3.13" colorama==0.4.6 ; python_version >= "3.10" and python_version < "3.13" and (sys_platform == "win32" or platform_system == "Windows") -dnspython==2.8.0 ; python_version >= "3.10" and python_version < "3.13" exceptiongroup==1.3.1 ; python_version == "3.10" fastapi==0.128.8 ; python_version >= "3.10" and python_version < "3.13" h11==0.16.0 ; python_version >= "3.10" and python_version < "3.13" @@ -15,13 +14,13 @@ markupsafe==3.0.3 ; python_version >= "3.10" and python_version < "3.13" pillow==12.1.1 ; python_version >= "3.10" and python_version < "3.13" pydantic-core==2.41.5 ; python_version >= "3.10" and python_version < "3.13" pydantic==2.12.5 ; python_version >= "3.10" and python_version < "3.13" -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" +tzdata==2025.3 ; python_version >= "3.10" and python_version < "3.13" uvicorn==0.40.0 ; python_version >= "3.10" and python_version < "3.13" validators==0.35.0 ; python_version >= "3.10" and python_version < "3.13"