Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
236b79d
refactor: remove unused CSS styles and improve notification system
Zingzy Apr 7, 2026
aa5c6aa
feat: implement unified notification system and remove legacy notific…
Zingzy Apr 7, 2026
c7a1fd3
refactor: replace hardcoded z-index values with CSS variables for imp…
Zingzy Apr 7, 2026
40cc392
refactor: clean up CSS by removing unused styles and optimizing selec…
Zingzy Apr 7, 2026
0f261f0
Remove inline styles from auth modal to improve maintainability and c…
Zingzy Apr 7, 2026
3d580b3
feat: implement dashboard functionality with filtering and pagination…
Zingzy Apr 7, 2026
f838dd1
refactor: extract analytics script into a separate partial for improv…
Zingzy Apr 7, 2026
ae459ef
refactor: update notification styles for improved visibility and cons…
Zingzy Apr 7, 2026
a179a7e
refactor: enhance form error handling and input validation
Zingzy Apr 18, 2026
c04f5d4
feat: implement dropdown component with associated styles and functio…
Zingzy Apr 18, 2026
a64fc10
feat: add shared modal tab functionality and refactor tab handling
Zingzy Apr 18, 2026
f784e19
feat: enhance modal tab error handling and validation
Zingzy Apr 18, 2026
2f0b525
feat: implement filter and time range persistence in dashboard
Zingzy Apr 18, 2026
7b77c61
feat: add delete key confirmation modal functionality
Zingzy Apr 19, 2026
7ecf98a
feat: implement chart/table view toggle functionality in dashboard
Zingzy Apr 19, 2026
13ff698
feat: add alias availability check functionality
Zingzy Apr 19, 2026
788e0e8
refactor: update dropdown components and styles for improved usability
Zingzy Apr 19, 2026
c6b7ddf
feat: add StaticCacheHeadersMiddleware for long-lived caching of stat…
Zingzy Apr 19, 2026
cb478f1
fix: ensure credentials are included in fetch requests and update not…
Zingzy Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from middleware.security import (
MaxContentLengthMiddleware,
SecurityHeadersMiddleware,
StaticCacheHeadersMiddleware,
configure_cors,
)
from repositories.indexes import ensure_indexes
Expand Down Expand Up @@ -226,6 +227,8 @@ async def docs(request: Request):
# 2. Security headers — must be outer so HSTS/CSP/nosniff apply to
# all responses including CORS preflights (204) and body-limit (413)
app.add_middleware(SecurityHeadersMiddleware, hsts_enabled=settings.is_production)
# 2a. Long-lived cache headers for /static/*
app.add_middleware(StaticCacheHeadersMiddleware)
# 3. CORS
configure_cors(app, settings)
# 4. Body size limit
Expand Down
4 changes: 4 additions & 0 deletions middleware/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ class Limits:
API_AUTHED = "60 per minute; 5000 per day"
API_ANON = "20 per minute; 1000 per day"

# Alias availability check — cheap read, UI debounces on each keystroke
API_CHECK_AUTHED = "180 per minute; 10000 per day"
API_CHECK_ANON = "60 per minute; 2000 per day"

# Auth endpoints
LOGIN = "5 per minute; 50 per day"
SIGNUP = "5 per minute; 50 per day"
Expand Down
16 changes: 16 additions & 0 deletions middleware/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ async def dispatch(
return response


class StaticCacheHeadersMiddleware(BaseHTTPMiddleware):
"""Set long-lived immutable Cache-Control on /static/* responses.

Templates cache-bust via ?v=N query strings, so each asset URL is
effectively content-addressed and safe to cache for a year.
"""

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
if request.url.path.startswith("/static/") and response.status_code == 200:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
Comment thread
Zingzy marked this conversation as resolved.


class MaxContentLengthMiddleware(BaseHTTPMiddleware):
"""Reject requests whose Content-Length exceeds the configured limit."""

Expand Down
37 changes: 34 additions & 3 deletions routes/api_v1/shorten.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from __future__ import annotations

from fastapi import APIRouter, Depends, Request
from typing import Annotated

from fastapi import APIRouter, Depends, Query, Request

from dependencies import (
SHORTEN_SCOPES,
Expand All @@ -18,13 +20,14 @@
)
from middleware.openapi import AUTH_RESPONSES, OPTIONAL_AUTH_SECURITY
from middleware.rate_limiter import Limits, dynamic_limit, limiter
from schemas.dto.requests.url import CreateUrlRequest
from schemas.dto.responses.url import UrlResponse
from schemas.dto.requests.url import AliasCheckQuery, CreateUrlRequest
from schemas.dto.responses.url import AliasCheckResponse, UrlResponse
from shared.ip_utils import get_client_ip

router = APIRouter(tags=["URL Shortening"])

_shorten_limit, _shorten_key = dynamic_limit(Limits.API_AUTHED, Limits.API_ANON)
_check_limit, _check_key = dynamic_limit(Limits.API_CHECK_AUTHED, Limits.API_CHECK_ANON)


@router.post(
Expand Down Expand Up @@ -69,3 +72,31 @@ async def shorten_v1(

doc = await url_service.create(body, owner_id, client_ip)
return UrlResponse.from_doc(doc, settings.app_url)


@router.get(
"/shorten/check-alias",
responses=AUTH_RESPONSES,
openapi_extra=OPTIONAL_AUTH_SECURITY,
operation_id="checkAliasAvailability",
summary="Check Alias Availability",
)
@limiter.limit(_check_limit, key_func=_check_key)
async def check_alias(
request: Request,
url_service: UrlSvc,
query: Annotated[AliasCheckQuery, Query()],
_user: CurrentUser | None = Depends(optional_scopes_verified(SHORTEN_SCOPES)), # noqa: B008
) -> AliasCheckResponse:
"""Check whether a proposed alias would be accepted by POST /api/v1/shorten.

Reason codes on a negative result (``length``/``format``/``taken``) let the
UI render precise inline feedback without duplicating the validation rules.

**Authentication**: Optional — higher rate limits when authenticated.
"""
result = await url_service.check_alias(query.alias)
return AliasCheckResponse(
available=result == "available",
reason=None if result == "available" else result,
)
16 changes: 16 additions & 0 deletions schemas/dto/requests/url.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,19 @@ def _parse_filter_json(self) -> ListUrlsQuery:
@property
def parsed_filter(self) -> UrlFilter | None:
return self._parsed_filter


class AliasCheckQuery(RequestBase):
"""Query parameters for GET /api/v1/shorten/check-alias.

Intentionally permissive on ``max_length`` so that out-of-range input returns
a structured ``{available: false, reason: "length"}`` response instead of a
422 — the UI surfaces the reason inline without re-implementing rules.
"""

alias: str = Field(
min_length=1,
max_length=64,
description="Candidate alias to check.",
examples=["mylink"],
)
16 changes: 16 additions & 0 deletions schemas/dto/responses/url.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,19 @@ class UrlListResponse(ResponseBase):
hasNext: bool
sortBy: str
sortOrder: str


class AliasCheckResponse(ResponseBase):
"""Response body for GET /api/v1/shorten/check-alias.

``available`` is true only when the alias passes format/length validation
AND is not already taken. When false, ``reason`` explains why so the UI
can render a precise, non-generic message.
"""

available: bool = Field(description="Whether the alias is free to use.")
reason: str | None = Field(
default=None,
description="When unavailable: 'length', 'format', or 'taken'.",
examples=["taken"],
)
19 changes: 19 additions & 0 deletions services/url_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import time
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import Literal

from bson import ObjectId

Expand Down Expand Up @@ -60,6 +61,8 @@

log = get_logger(__name__)

AliasCheckResult = Literal["available", "length", "format", "taken"]

# ── Field update handlers ────────────────────────────────────────────────────
#
# Each handler inspects one field on the update request and, if changed,
Expand Down Expand Up @@ -284,6 +287,22 @@ async def check_alias_available(self, alias: str) -> bool:
return False
return not await self._legacy_repo.check_exists(alias)

async def check_alias(self, alias: str) -> AliasCheckResult:
"""Evaluate a candidate alias against the full creation rules.

Mirrors what POST /api/v1/shorten would enforce (length, charset,
collision) so the UI can surface precise feedback without duplicating
the rules. Returns a single literal describing the first failing check,
or ``"available"`` when the alias would be accepted today.
"""
if not (3 <= len(alias) <= 16):
return "length"
if not validate_alias(alias):
return "format"
if not await self.check_alias_available(alias):
return "taken"
return "available"
Comment thread
Zingzy marked this conversation as resolved.

async def create(
self,
request: CreateUrlRequest,
Expand Down
63 changes: 45 additions & 18 deletions static/css/api.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ body {
padding: 10px;
}

.custom-table th, .custom-table td {
.custom-table th,
.custom-table td {
border: 1px solid #353535;
text-align: left;
}
Expand All @@ -77,27 +78,33 @@ body {
font-weight: bold;
}

h1, h2, h3, h4 {
h1,
h2,
h3,
h4 {
font-weight: bolder;
}

.example-codes > div,
.emoji-example-codes > div,
.stats-example-codes > div,
.export-example-codes > div {
.example-codes>div,
.emoji-example-codes>div,
.stats-example-codes>div,
.export-example-codes>div {
display: none;
}

.example-codes > div.active-code,
.emoji-example-codes > div.active-code,
.stats-example-codes > div.active-code,
.export-example-codes > div.active-code {
.example-codes>div.active-code,
.emoji-example-codes>div.active-code,
.stats-example-codes>div.active-code,
.export-example-codes>div.active-code {
display: block;
}


/* Dock styling */
.languages-dock, .emoji-languages-dock, .stats-languages-dock, .export-languages-dock {
.languages-dock,
.emoji-languages-dock,
.stats-languages-dock,
.export-languages-dock {
display: flex;
justify-content: flex-start;
gap: 3px;
Expand All @@ -108,17 +115,26 @@ h1, h2, h3, h4 {
overflow-x: scroll;
}

.languages-dock::-webkit-scrollbar, .emoji-languages-dock::-webkit-scrollbar, .stats-languages-dock::-webkit-scrollbar, .export-languages-dock::-webkit-scrollbar {
.languages-dock::-webkit-scrollbar,
.emoji-languages-dock::-webkit-scrollbar,
.stats-languages-dock::-webkit-scrollbar,
.export-languages-dock::-webkit-scrollbar {
display: none;
}

.languages-dock::-webkit-scrollbar-track, .emoji-languages-dock::-webkit-scrollbar-track, .stats-languages-dock::-webkit-scrollbar-track, .export-languages-dock::-webkit-scrollbar-track {
.languages-dock::-webkit-scrollbar-track,
.emoji-languages-dock::-webkit-scrollbar-track,
.stats-languages-dock::-webkit-scrollbar-track,
.export-languages-dock::-webkit-scrollbar-track {
background-color: transparent;
}


/* Button styling */
.language-button, .emoji-language-button, .stats-language-button, .export-language-button {
.language-button,
.emoji-language-button,
.stats-language-button,
.export-language-button {
background-color: #444;
color: white;
border: none;
Expand All @@ -135,13 +151,19 @@ h1, h2, h3, h4 {
}

/* Button hover effect */
.language-button:hover, .emoji-language-button:hover, .stats-language-button:hover, .export-language-button:hover {
.language-button:hover,
.emoji-language-button:hover,
.stats-language-button:hover,
.export-language-button:hover {
background-color: #555;
color: white;
}

/* Active button styling */
.language-button.active, .emoji-language-button.active, .stats-language-button.active, .export-language-button.active {
.language-button.active,
.emoji-language-button.active,
.stats-language-button.active,
.export-language-button.active {
background-color: rgb(25, 25, 25);
color: white;
border: none;
Expand All @@ -160,12 +182,15 @@ h1, h2, h3, h4 {
from {
transform: scaleX(0);
}

to {
transform: scaleX(1);
}
}

.language-button::before, .emoji-language-button::before, .stats-language-button::before {
.language-button::before,
.emoji-language-button::before,
.stats-language-button::before {
content: "";
position: absolute;
width: 100%;
Expand All @@ -178,7 +203,9 @@ h1, h2, h3, h4 {
transition: all 0.3s ease-in-out 0s;
}

.language-button:hover::before, .emoji-language-button:hover::before, .stats-language-button:hover::before {
.language-button:hover::before,
.emoji-language-button:hover::before,
.stats-language-button:hover::before {
visibility: visible;
transform: scaleX(1);
}
Expand Down
Loading
Loading