diff --git a/.github/workflows/project-assign.yml b/.github/workflows/project-assign.yml index 7e1f4fd..79479b3 100644 --- a/.github/workflows/project-assign.yml +++ b/.github/workflows/project-assign.yml @@ -4,7 +4,7 @@ run-name: Assign project under issue and pull requests on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened, reopened] permissions: diff --git a/.gitignore b/.gitignore index 8a7644e..d0aec68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .venv* venv/ +.venv-dist/ env/ .venv* diff --git a/app/main.py b/app/main.py index 932e5e4..a1d2d60 100644 --- a/app/main.py +++ b/app/main.py @@ -3,18 +3,12 @@ from pathlib import Path import logging import asyncio - from fastapi import FastAPI, Request - from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware - -# 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 @@ -115,26 +109,6 @@ async def lifespan(app: FastAPI): name="qr", ) -# ----------------------------- -# 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): diff --git a/app/routes.py b/app/routes.py index ba5f165..034d54c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -34,11 +34,21 @@ 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.config import ( + DOMAIN, + MAX_RECENT_URLS, + CACHE_PURGE_TOKEN, + QR_DIR, +) +from app.utils.helper import ( + generate_code, + sanitize_url, + is_valid_url, + authorize_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() @@ -98,12 +108,18 @@ async def create_short_url( qr_type: str = Form("short"), ): session = request.session - original_url = sanitize_url(original_url) + original_url = sanitize_url(original_url) # sanitize the URL input - if not original_url or not is_valid_url(original_url): + if not original_url or not is_valid_url(original_url): # validate the URL session["error"] = "Please enter a valid URL." return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + if not authorize_url( + original_url + ): # authorize the URL based on whitelist/blacklist + session["error"] = "This domain is not allowed." + 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(): @@ -219,7 +235,6 @@ def redirect_short_ui(short_code: str, background_tasks: BackgroundTasks): 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") @@ -331,9 +346,13 @@ class ShortenRequest(BaseModel): @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"}) + if not authorize_url(original_url): + return JSONResponse(status_code=400, content={"error": "DOMAIN_NOT_ALLOWED"}) + short_code = get_short_from_cache(original_url) if not short_code: short_code = generate_code() diff --git a/app/static/css/tiny.css b/app/static/css/tiny.css index daf3df1..3dd195f 100644 --- a/app/static/css/tiny.css +++ b/app/static/css/tiny.css @@ -16,29 +16,21 @@ 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; + max-width: 940px; margin: 0 auto; padding: 6rem 1rem 4rem; display: flex; @@ -69,9 +61,7 @@ body.light-theme { 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); } @@ -202,6 +192,13 @@ body.dark-theme .app-header { -webkit-text-fill-color: transparent; } +.alert-error { + text-align: center; + color: #ff6b6b; + margin-top: 8px; + font-size: 14px; +} + .input-wrapper { display: flex; gap: 1rem; @@ -250,7 +247,7 @@ body.dark-theme .app-header { cursor: pointer; } -/* Result card */ + .result-card { width: 100%; background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(0, 0, 0, 0)); @@ -323,10 +320,10 @@ body.dark-theme .app-header { color: var(--accent); } -/* Recent tray */ .recent-tray { width: 100%; margin-top: 2rem; + overflow: visible; } .recent-header { @@ -337,11 +334,14 @@ body.dark-theme .app-header { align-items: center; } + .scroll-container { display: flex; gap: 1rem; overflow-x: auto; - padding: 1rem 0; + padding: 1rem 16px; + scroll-padding-right: 16px; + box-sizing: border-box; } .scroll-container::-webkit-scrollbar { @@ -349,7 +349,8 @@ body.dark-theme .app-header { } .recent-item { - min-width: 220px; + width: 260px; + flex: 0 0 auto; background: var(--glass); border: 1px solid var(--glass-border); padding: 1rem; @@ -360,6 +361,10 @@ body.dark-theme .app-header { flex-shrink: 0; } +.recent-item:last-child { + margin-right: 16px; +} + .short-code { color: var(--accent); font-weight: bold; @@ -378,23 +383,16 @@ body.dark-theme .app-header { /* =============================== 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; } @@ -411,7 +409,6 @@ body.dark-theme .app-header { min-width: 800px; } -/* Header */ .recent-table thead { background: var(--glass); } @@ -428,7 +425,6 @@ body.dark-theme .app-header { white-space: nowrap; } -/* Body cells */ .recent-table td { padding: 14px; font-size: 14px; @@ -439,7 +435,6 @@ body.dark-theme .app-header { white-space: nowrap; } -/* Row hover */ .recent-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); } @@ -448,7 +443,6 @@ body.dark-theme .app-header { COLUMN WIDTH CONTROL ================================= */ -/* # column */ .recent-table th:nth-child(1), .recent-table td:nth-child(1) { width: 45px; @@ -457,26 +451,22 @@ body.dark-theme .app-header { 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; @@ -485,7 +475,6 @@ body.dark-theme .app-header { color: var(--accent-2); } -/* Actions */ .recent-table th:nth-child(6), .recent-table td:nth-child(6) { width: 120px; @@ -506,7 +495,6 @@ body.dark-theme .app-header { text-decoration: underline; } -/* Original URL truncate */ .original-url { word-break: break-all; } @@ -523,7 +511,6 @@ body.dark-theme .app-header { color: var(--accent); } -/* Created time */ .created-time { font-size: 13px; color: var(--muted); @@ -572,21 +559,19 @@ body.dark-theme .app-header { border-bottom: 1px solid var(--glass-border); } -/* Tablet */ + @media (max-width: 1024px) { .recent-page-container { padding: 0 18px; } } -/* Mobile */ @media (max-width: 768px) { .recent-page-container { padding: 0 12px; } } -/* Small phones */ @media (max-width: 480px) { .recent-page-container { padding: 0 8px; @@ -739,31 +724,26 @@ body.dark-theme .footer-bottom a { } } +.recent-tray .recent-item .original-url { + max-width: 350px; + min-width: 0; +} - - -/* 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; - + -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; - white-space: normal; - word-break: break-word; + word-break: break-all; overflow-wrap: anywhere; } -/* IMPORTANT — remove width restriction */ .recent-tray .recent-item { min-width: 0; - /* allows shrinking inside flex/grid */ max-width: 100%; } diff --git a/app/static/og-image.png b/app/static/og-image.png new file mode 100644 index 0000000..96f3ccf Binary files /dev/null and b/app/static/og-image.png differ diff --git a/app/templates/index.html b/app/templates/index.html index a4a9a77..01517f1 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,10 +4,17 @@

Shorten Your Links

+ value="{{ original_url or '' }}" required> + +
Analytics Enabled
+ {% if error %} +
+ ⚠ {{ error }} +
+ {% endif %} {% if new_short_url %} diff --git a/app/templates/layout.html b/app/templates/layout.html index 23d8a6f..33ec416 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -6,10 +6,19 @@ {% block title %}tiny URL{% endblock %} - + + + + + - - + + + + + + list[RecentItem]: for original_url, data in rev_cache.items(): if data["expires_at"] >= now: + short_code = data["short_code"] valid_items.append( { "short_code": data["short_code"], "original_url": original_url, "created_at": data["created_at"], + "visit_count": visit_cache.get(short_code, 0), } ) diff --git a/app/utils/config.py b/app/utils/config.py index 30b41a3..6024230 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -82,8 +82,16 @@ def _get_int(key: str, default: int) -> int: 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" +QR_DIR = PROJECT_ROOT / "assets" / "images" / "qr" # QR directory constant + + +# for the check of the url which is blacklist or whitelist +whitelist_urls: set[str] = set() +blacklist_urls: set[str] = { + "malware.com", + "phishing.site", + "badsite.test", + "spam.test", +} diff --git a/app/utils/helper.py b/app/utils/helper.py index 1241ad2..0434beb 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -3,22 +3,72 @@ from datetime import datetime, timezone from zoneinfo import ZoneInfo from typing import Union +from app.utils.config import SHORT_CODE_LENGTH, whitelist_urls, blacklist_urls +from urllib.parse import urlparse +import ipaddress -import validators -from app.utils.config import SHORT_CODE_LENGTH + +# for sanitization the url +def sanitize_url(url: str) -> str: + return url.strip() +# for validating the url def is_valid_url(url: str) -> bool: - return bool(validators.url(url)) + try: + parsed = urlparse(url) -def sanitize_url(url: str) -> str: - url = url.strip() - if not url: - return "" - if not url.startswith(("http://", "https://")): - url = "https://" + url - return url + # Allow only http/https + if parsed.scheme not in ("http", "https"): + return False + + # Must have hostname + if not parsed.netloc: + return False + + hostname = parsed.hostname + + # Handle None (fix for mypy) + if hostname is None: + return False + + # Block private / loopback IPs + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback: + return False + except ValueError: + # Hostname is not an IP (normal domain) + pass + + return True + + except Exception: + return False + + +# for authorizing the url based on whitelist and blacklist +def authorize_url(url: str) -> bool: + """Check whitelist / blacklist rules.""" + hostname = urlparse(url).hostname + + if hostname is None: + return False + + hostname = hostname.lower() + + # block blacklist domains + if any(hostname.endswith(domain) for domain in blacklist_urls): + return False + + # allow only whitelist domains (if defined) + if whitelist_urls and not any( + hostname.endswith(domain) for domain in whitelist_urls + ): + return False + + return True def generate_code(length: int = SHORT_CODE_LENGTH) -> str: diff --git a/requirements.txt b/requirements.txt index a420fad..11ec347 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ annotated-doc==0.0.4 ; python_version >= "3.10" and python_version < "3.13" annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "3.13" 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" @@ -14,10 +14,10 @@ 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" -python-dotenv==1.2.1 ; 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.2 ; 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.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"