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 @@