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/app/routes.py b/app/routes.py index ba5f165..4607456 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,4 +1,5 @@ import os +import re from datetime import datetime, timezone from typing import Optional from app.utils.cache import list_cache_clean, clear_cache @@ -98,12 +99,25 @@ async def create_short_url( qr_type: str = Form("short"), ): session = request.session - original_url = sanitize_url(original_url) + original_url = original_url.strip() - if not original_url or not is_valid_url(original_url): + if not original_url.startswith(("http://", "https://")): + if re.match(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+){1,2}$", original_url): + original_url = "https://" + original_url + else: + session["error"] = "Please enter a valid URL." + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + if not is_valid_url(original_url): session["error"] = "Please enter a valid URL." return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + # 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(): diff --git a/app/static/css/tiny.css b/app/static/css/tiny.css index daf3df1..96c0673 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,16 @@ body.dark-theme .app-header { -webkit-text-fill-color: transparent; } +.alert-error { + background: #ffecec; + border: 1px solid #ffb3b3; + color: #cc0000; + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 14px; + font-size: 14px; +} + .input-wrapper { display: flex; gap: 1rem; @@ -250,7 +250,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 +323,10 @@ body.dark-theme .app-header { color: var(--accent); } -/* Recent tray */ .recent-tray { width: 100%; margin-top: 2rem; + overflow: visible; } .recent-header { @@ -337,11 +337,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 +352,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 +364,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 +386,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 +412,6 @@ body.dark-theme .app-header { min-width: 800px; } -/* Header */ .recent-table thead { background: var(--glass); } @@ -428,7 +428,6 @@ body.dark-theme .app-header { white-space: nowrap; } -/* Body cells */ .recent-table td { padding: 14px; font-size: 14px; @@ -439,7 +438,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 +446,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 +454,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 +478,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 +498,6 @@ body.dark-theme .app-header { text-decoration: underline; } -/* Original URL truncate */ .original-url { word-break: break-all; } @@ -523,7 +514,6 @@ body.dark-theme .app-header { color: var(--accent); } -/* Created time */ .created-time { font-size: 13px; color: var(--muted); @@ -572,21 +562,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 +727,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/templates/index.html b/app/templates/index.html index a4a9a77..8f34762 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,9 +2,16 @@

Shorten Your Links

+ {% if error %} +
+ ⚠ {{ error }} +
+ {% endif %}
+ value="{{ original_url or '' }}" required> + +
Analytics Enabled
diff --git a/app/utils/helper.py b/app/utils/helper.py index 1241ad2..b156af0 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -3,21 +3,64 @@ from datetime import datetime, timezone from zoneinfo import ZoneInfo from typing import Union - -import validators from app.utils.config import SHORT_CODE_LENGTH +from urllib.parse import urlparse +import ipaddress +import re + +# def is_valid_url(url: str) -> bool: +# return bool(validators.url(url)) def is_valid_url(url: str) -> bool: - return bool(validators.url(url)) + try: + parsed = urlparse(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 + + # Block localhost + if hostname in ("localhost",): + 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 + + +# def sanitize_url(url: str) -> str: +# url = url.strip() +# if not url: +# return "" +# if not url.startswith(("http://", "https://")): +# url = "https://" + url +# return url def sanitize_url(url: str) -> str: url = url.strip() - if not url: - return "" + if not url.startswith(("http://", "https://")): - url = "https://" + url + if re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", url): + url = "https://" + url + return url 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"