Code-API commited on
Commit
f87115f
·
1 Parent(s): 10ca996

deploy: auto-deploy 04:41:22

Browse files
app/api/server.py CHANGED
@@ -88,6 +88,7 @@ def create_application() -> FastAPI:
88
  {"name": "Embeddings", "description": "Text embedding generation using transformer models"},
89
  {"name": "Verify", "description": "Phone number and identity verification"},
90
  {"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
 
91
  ],
92
  lifespan=lifespan,
93
  )
 
88
  {"name": "Embeddings", "description": "Text embedding generation using transformer models"},
89
  {"name": "Verify", "description": "Phone number and identity verification"},
90
  {"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
91
+ {"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
92
  ],
93
  lifespan=lifespan,
94
  )
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, vector_stores, web_search, webhook_socket
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -24,3 +24,4 @@ api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
26
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
 
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, url_shortener, vector_stores, web_search, webhook_socket
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
26
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
27
+ api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
app/api/v1/url_shortener.py ADDED
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query
8
+ from fastapi.responses import PlainTextResponse
9
+ from pydantic import BaseModel, Field
10
+
11
+ from app.config import get_settings
12
+ from app.services.url_shortener_service import (
13
+ URLShortenerService,
14
+ AliasTakenError,
15
+ AuthenticationError,
16
+ AuthorizationError,
17
+ LinkExpiredError,
18
+ LinkNotFoundError,
19
+ PlanLimitExceededError,
20
+ URLShortenerError,
21
+ ValidationError,
22
+ )
23
+
24
+ _settings = get_settings()
25
+ router = APIRouter()
26
+
27
+ _service_lock = threading.Lock()
28
+ _shared_service: Optional[URLShortenerService] = None
29
+
30
+
31
+ def _get_service() -> URLShortenerService:
32
+ global _shared_service
33
+ if _shared_service is None:
34
+ with _service_lock:
35
+ if _shared_service is None:
36
+ _shared_service = URLShortenerService()
37
+ return _shared_service
38
+
39
+
40
+ def _app_id() -> str:
41
+ return _settings.application_id or ""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Request / Response models
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class CreateOwnerRequest(BaseModel):
49
+ name: str = Field(..., min_length=1, max_length=200)
50
+ plan: Optional[str] = Field("free", description="free, pro, or enterprise")
51
+
52
+
53
+ class CreateOwnerResponse(BaseModel):
54
+ success: bool = True
55
+ owner_id: str
56
+ api_key: str
57
+ plan: str
58
+ application_id: str = ""
59
+
60
+
61
+ class ShortenRequest(BaseModel):
62
+ long_url: str = Field(..., description="Destination URL (http/https only)")
63
+ custom_alias: Optional[str] = Field(None, description="Custom short code (3-32 chars, letters/digits/-/_)")
64
+ expires_in_days: Optional[int] = Field(None, ge=1)
65
+ max_clicks: Optional[int] = Field(None, ge=1)
66
+ tags: Optional[List[str]] = Field(None, description="Tags (max 20)")
67
+ note: Optional[str] = Field(None, description="Max 2000 chars")
68
+ password: Optional[str] = Field(None, description="Password-protect the link")
69
+ utm_source: Optional[str] = Field(None)
70
+ utm_medium: Optional[str] = Field(None)
71
+ utm_campaign: Optional[str] = Field(None)
72
+ campaign_id: Optional[str] = Field(None, description="Campaign to group this link under")
73
+ custom_domain: Optional[str] = Field(None, description="Branded domain override")
74
+ fallback_url: Optional[str] = Field(None, description="Redirect here when link is expired/deactivated")
75
+ webhook_url: Optional[str] = Field(None, description="URL to POST click events to (Enterprise)")
76
+ geo_targeting: Optional[Dict[str, Any]] = Field(None, description='{"countries": {...}, "devices": {...}}')
77
+
78
+
79
+ class ShortLinkResponse(BaseModel):
80
+ success: bool = True
81
+ short_code: str
82
+ long_url: str
83
+ owner_id: str
84
+ created_at: str
85
+ expires_at: Optional[str] = None
86
+ max_clicks: Optional[int] = None
87
+ click_count: int = 0
88
+ is_active: bool = True
89
+ tags: List[str] = []
90
+ note: Optional[str] = None
91
+ has_password: bool = False
92
+ short_url: Optional[str] = None
93
+ qr_image_url: Optional[str] = None
94
+ campaign_id: Optional[str] = None
95
+ custom_domain: Optional[str] = None
96
+ fallback_url: Optional[str] = None
97
+ webhook_url: Optional[str] = None
98
+ application_id: str = ""
99
+
100
+
101
+ class UpdateLinkRequest(BaseModel):
102
+ long_url: Optional[str] = None
103
+ note: Optional[str] = None
104
+ tags: Optional[List[str]] = None
105
+ max_clicks: Optional[int] = None
106
+ password: Optional[str] = None
107
+ webhook_url: Optional[str] = None
108
+ fallback_url: Optional[str] = None
109
+ campaign_id: Optional[str] = None
110
+ custom_domain: Optional[str] = None
111
+ geo_targeting: Optional[Dict[str, Any]] = None
112
+
113
+
114
+ class ListLinksResponse(BaseModel):
115
+ success: bool = True
116
+ links: List[ShortLinkResponse]
117
+ application_id: str = ""
118
+
119
+
120
+ class StatsResponse(BaseModel):
121
+ success: bool = True
122
+ short_code: str
123
+ long_url: str
124
+ click_count: int
125
+ created_at: str
126
+ last_accessed_at: Optional[str] = None
127
+ is_active: bool
128
+ expires_at: Optional[str] = None
129
+ max_clicks: Optional[int] = None
130
+ has_password: bool = False
131
+ utm: Optional[Dict[str, Any]] = None
132
+ analytics: Optional[Dict[str, Any]] = None
133
+ campaign_id: Optional[str] = None
134
+ custom_domain: Optional[str] = None
135
+ fallback_url: Optional[str] = None
136
+ webhook_url: Optional[str] = None
137
+ application_id: str = ""
138
+
139
+
140
+ class ClickDetail(BaseModel):
141
+ id: int
142
+ referrer: Optional[str] = None
143
+ user_agent: Optional[str] = None
144
+ ip_address: Optional[str] = None
145
+ browser: Optional[str] = None
146
+ device: Optional[str] = None
147
+ os: Optional[str] = None
148
+ clicked_at: str
149
+
150
+
151
+ class ClickDetailsResponse(BaseModel):
152
+ success: bool = True
153
+ clicks: List[ClickDetail]
154
+ application_id: str = ""
155
+
156
+
157
+ class QRCodeResponse(BaseModel):
158
+ success: bool = True
159
+ short_code: str
160
+ short_url: str
161
+ qr_image_url: str
162
+ application_id: str = ""
163
+
164
+
165
+ class ErrorResponse(BaseModel):
166
+ success: bool = False
167
+ error: str
168
+
169
+
170
+ class BulkShortenRequest(BaseModel):
171
+ items: List[Dict[str, Any]] = Field(..., description="Array of shorten requests (max 100)")
172
+
173
+
174
+ class BulkShortenResponse(BaseModel):
175
+ success: bool = True
176
+ results: List[Dict[str, Any]]
177
+ application_id: str = ""
178
+
179
+
180
+ class OwnerSummaryResponse(BaseModel):
181
+ success: bool = True
182
+ owner_id: str
183
+ name: str
184
+ plan: str
185
+ total_links: int
186
+ active_links: int
187
+ total_clicks: int
188
+ created_at: str
189
+ application_id: str = ""
190
+
191
+
192
+ class CampaignCreateRequest(BaseModel):
193
+ name: str = Field(..., min_length=1, max_length=200)
194
+ description: Optional[str] = Field(None, max_length=2000)
195
+
196
+
197
+ class CampaignResponse(BaseModel):
198
+ success: bool = True
199
+ campaign_id: str
200
+ name: str
201
+ description: Optional[str] = None
202
+ created_at: Optional[str] = None
203
+ is_active: Optional[bool] = None
204
+ link_count: Optional[int] = 0
205
+ total_clicks: Optional[int] = 0
206
+ analytics: Optional[Dict[str, Any]] = None
207
+ application_id: str = ""
208
+
209
+
210
+ class CampaignListResponse(BaseModel):
211
+ success: bool = True
212
+ campaigns: List[CampaignResponse]
213
+ application_id: str = ""
214
+
215
+
216
+ class HealthCheckResponse(BaseModel):
217
+ success: bool = True
218
+ short_code: str
219
+ long_url: str
220
+ reachable: bool
221
+ status_code: Optional[int] = None
222
+ error: Optional[str] = None
223
+ application_id: str = ""
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Helpers
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def _auth_owner(x_api_key: str = Header(..., alias="X-API-Key")) -> str:
231
+ service = _get_service()
232
+ try:
233
+ return service.authenticate(x_api_key)
234
+ except AuthenticationError:
235
+ raise HTTPException(status_code=401, detail={"success": False, "error": "Invalid API key"})
236
+
237
+
238
+ def _build_link_response(link, service) -> ShortLinkResponse:
239
+ return ShortLinkResponse(
240
+ short_code=link.short_code,
241
+ long_url=link.long_url,
242
+ owner_id=link.owner_id,
243
+ created_at=link.created_at.isoformat(),
244
+ expires_at=link.expires_at.isoformat() if link.expires_at else None,
245
+ max_clicks=link.max_clicks,
246
+ click_count=link.click_count,
247
+ is_active=link.is_active,
248
+ tags=link.tags,
249
+ note=link.note,
250
+ has_password=bool(link.password_hash) if hasattr(link, 'password_hash') else False,
251
+ campaign_id=link.campaign_id,
252
+ custom_domain=link.custom_domain,
253
+ fallback_url=link.fallback_url,
254
+ webhook_url=link.webhook_url,
255
+ application_id=_app_id(),
256
+ )
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Owner / Plan
261
+ # ---------------------------------------------------------------------------
262
+
263
+ @router.post(
264
+ "/url-shortener/owners",
265
+ response_model=CreateOwnerResponse,
266
+ summary="Create a new owner (tenant) and issue API key",
267
+ )
268
+ def create_owner(body: CreateOwnerRequest):
269
+ service = _get_service()
270
+ result = service.create_owner(body.name, body.plan or "free")
271
+ return CreateOwnerResponse(
272
+ owner_id=result["owner_id"],
273
+ api_key=result["api_key"],
274
+ plan=body.plan or "free",
275
+ application_id=_app_id(),
276
+ )
277
+
278
+
279
+ @router.patch(
280
+ "/url-shortener/plan",
281
+ summary="Update owner's subscription plan",
282
+ )
283
+ def update_plan(plan: str = Query(..., description="free, pro, or enterprise"),
284
+ owner_id: str = Depends(_auth_owner)):
285
+ service = _get_service()
286
+ try:
287
+ return {"success": True, "application_id": _app_id(), **service.update_owner_plan(owner_id, plan)}
288
+ except ValidationError as e:
289
+ raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
290
+
291
+
292
+ @router.get(
293
+ "/url-shortener/summary",
294
+ response_model=OwnerSummaryResponse,
295
+ summary="Get owner dashboard summary",
296
+ )
297
+ def owner_summary(owner_id: str = Depends(_auth_owner)):
298
+ service = _get_service()
299
+ data = service.owner_summary(owner_id)
300
+ if not data:
301
+ raise HTTPException(status_code=404, detail={"success": False, "error": "Owner not found"})
302
+ return OwnerSummaryResponse(application_id=_app_id(), **data)
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Short links CRUD
307
+ # ---------------------------------------------------------------------------
308
+
309
+ @router.post(
310
+ "/url-shortener/shorten",
311
+ response_model=ShortLinkResponse,
312
+ summary="Create a short link",
313
+ )
314
+ def shorten_url(body: ShortenRequest, owner_id: str = Depends(_auth_owner)):
315
+ service = _get_service()
316
+ try:
317
+ link = service.shorten(
318
+ long_url=body.long_url,
319
+ owner_id=owner_id,
320
+ custom_alias=body.custom_alias,
321
+ expires_in_days=body.expires_in_days,
322
+ max_clicks=body.max_clicks,
323
+ tags=body.tags,
324
+ note=body.note,
325
+ password=body.password,
326
+ utm_source=body.utm_source,
327
+ utm_medium=body.utm_medium,
328
+ utm_campaign=body.utm_campaign,
329
+ campaign_id=body.campaign_id,
330
+ custom_domain=body.custom_domain,
331
+ fallback_url=body.fallback_url,
332
+ webhook_url=body.webhook_url,
333
+ geo_targeting=body.geo_targeting,
334
+ )
335
+ from app.services.url_shortener_service import SRV_BASE_URL
336
+ resp = _build_link_response(link, service)
337
+ short_url = f"{SRV_BASE_URL}/url-shortener/{link.short_code}"
338
+ if link.custom_domain:
339
+ short_url = f"https://{link.custom_domain}/{link.short_code}"
340
+ resp.short_url = short_url
341
+ resp.qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={short_url}"
342
+ return resp
343
+ except ValidationError as e:
344
+ raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
345
+ except AuthenticationError as e:
346
+ raise HTTPException(status_code=401, detail={"success": False, "error": str(e)})
347
+ except AliasTakenError as e:
348
+ raise HTTPException(status_code=409, detail={"success": False, "error": str(e)})
349
+ except PlanLimitExceededError as e:
350
+ raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
351
+ except URLShortenerError as e:
352
+ raise HTTPException(status_code=500, detail={"success": False, "error": str(e)})
353
+
354
+
355
+ @router.post(
356
+ "/url-shortener/bulk",
357
+ response_model=BulkShortenResponse,
358
+ summary="Bulk create short links",
359
+ )
360
+ def bulk_shorten(body: BulkShortenRequest, owner_id: str = Depends(_auth_owner)):
361
+ if len(body.items) > 100:
362
+ raise HTTPException(status_code=400, detail={"success": False, "error": "Bulk limit is 100 items per request"})
363
+ service = _get_service()
364
+ results = service.bulk_shorten(owner_id, body.items)
365
+ return BulkShortenResponse(results=results, application_id=_app_id())
366
+
367
+
368
+ @router.get(
369
+ "/url-shortener/links",
370
+ response_model=ListLinksResponse,
371
+ summary="List all links for the authenticated owner",
372
+ )
373
+ def list_links(owner_id: str = Depends(_auth_owner)):
374
+ service = _get_service()
375
+ links = service.list_links(owner_id)
376
+ items = [_build_link_response(link, service) for link in links]
377
+ return ListLinksResponse(links=items, application_id=_app_id())
378
+
379
+
380
+ @router.get(
381
+ "/url-shortener/export",
382
+ summary="Export all links as CSV",
383
+ )
384
+ def export_links_csv(owner_id: str = Depends(_auth_owner)):
385
+ service = _get_service()
386
+ csv_data = service.export_links(owner_id)
387
+ return PlainTextResponse(csv_data, media_type="text/csv",
388
+ headers={"Content-Disposition": "attachment; filename=links_export.csv"})
389
+
390
+
391
+ @router.post(
392
+ "/url-shortener/campaigns",
393
+ response_model=CampaignResponse,
394
+ summary="Create a campaign to group links",
395
+ )
396
+ def create_campaign(body: CampaignCreateRequest, owner_id: str = Depends(_auth_owner)):
397
+ service = _get_service()
398
+ try:
399
+ result = service.create_campaign(owner_id, body.name, body.description)
400
+ return CampaignResponse(
401
+ campaign_id=result["campaign_id"],
402
+ name=result["name"],
403
+ description=result.get("description"),
404
+ application_id=_app_id(),
405
+ )
406
+ except PlanLimitExceededError as e:
407
+ raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
408
+ except ValidationError as e:
409
+ raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
410
+
411
+
412
+ @router.get(
413
+ "/url-shortener/campaigns",
414
+ response_model=CampaignListResponse,
415
+ summary="List all campaigns for the authenticated owner",
416
+ )
417
+ def list_campaigns(owner_id: str = Depends(_auth_owner)):
418
+ service = _get_service()
419
+ campaigns = service.list_campaigns(owner_id)
420
+ items = [CampaignResponse(**c, application_id=_app_id()) for c in campaigns]
421
+ return CampaignListResponse(campaigns=items, application_id=_app_id())
422
+
423
+
424
+ @router.get(
425
+ "/url-shortener/campaigns/{campaign_id}",
426
+ response_model=CampaignResponse,
427
+ summary="Get campaign details with analytics",
428
+ )
429
+ def get_campaign(
430
+ campaign_id: str = Path(...),
431
+ owner_id: str = Depends(_auth_owner),
432
+ ):
433
+ service = _get_service()
434
+ try:
435
+ data = service.get_campaign(campaign_id, owner_id)
436
+ return CampaignResponse(**data, application_id=_app_id())
437
+ except LinkNotFoundError as e:
438
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
439
+ except AuthorizationError as e:
440
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
441
+
442
+
443
+ @router.get(
444
+ "/url-shortener/campaigns/{campaign_id}/links",
445
+ response_model=ListLinksResponse,
446
+ summary="List all links in a campaign",
447
+ )
448
+ def list_campaign_links(
449
+ campaign_id: str = Path(...),
450
+ owner_id: str = Depends(_auth_owner),
451
+ ):
452
+ service = _get_service()
453
+ try:
454
+ links = service.list_campaign_links(campaign_id, owner_id)
455
+ items = [_build_link_response(link, service) for link in links]
456
+ return ListLinksResponse(links=items, application_id=_app_id())
457
+ except LinkNotFoundError as e:
458
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
459
+ except AuthorizationError as e:
460
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
461
+
462
+
463
+ @router.delete(
464
+ "/url-shortener/campaigns/{campaign_id}",
465
+ summary="Deactivate a campaign",
466
+ )
467
+ def deactivate_campaign(
468
+ campaign_id: str = Path(...),
469
+ owner_id: str = Depends(_auth_owner),
470
+ ):
471
+ service = _get_service()
472
+ try:
473
+ service.deactivate_campaign(campaign_id, owner_id)
474
+ return {"success": True, "message": f"Campaign '{campaign_id}' deactivated", "application_id": _app_id()}
475
+ except LinkNotFoundError as e:
476
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
477
+ except AuthorizationError as e:
478
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # Short link path-parameter routes (must come after static routes)
483
+ # ---------------------------------------------------------------------------
484
+
485
+ @router.get(
486
+ "/url-shortener/{short_code}",
487
+ summary="Resolve a short code (redirect to long URL)",
488
+ )
489
+ def resolve_short_code(
490
+ short_code: str = Path(...),
491
+ x_link_password: Optional[str] = Header(None, alias="X-Link-Password"),
492
+ referer: Optional[str] = Header(None, alias="Referer"),
493
+ user_agent: Optional[str] = Header(None, alias="User-Agent"),
494
+ x_forwarded_for: Optional[str] = Header(None, alias="X-Forwarded-For"),
495
+ ):
496
+ service = _get_service()
497
+ from fastapi.responses import RedirectResponse
498
+ ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else None
499
+ try:
500
+ if x_link_password:
501
+ long_url = service.resolve_with_password(short_code, x_link_password, referer, user_agent, ip)
502
+ else:
503
+ long_url = service.resolve(short_code, referer, user_agent, ip)
504
+ return RedirectResponse(url=long_url, status_code=302)
505
+ except LinkNotFoundError as e:
506
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
507
+ except LinkExpiredError as e:
508
+ raise HTTPException(status_code=410, detail={"success": False, "error": str(e)})
509
+ except AuthorizationError as e:
510
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
511
+
512
+
513
+ @router.patch(
514
+ "/url-shortener/{short_code}",
515
+ response_model=ShortLinkResponse,
516
+ summary="Update a short link (change URL, tags, webhook, etc.)",
517
+ )
518
+ def update_short_link(
519
+ body: UpdateLinkRequest,
520
+ short_code: str = Path(...),
521
+ owner_id: str = Depends(_auth_owner),
522
+ ):
523
+ service = _get_service()
524
+ try:
525
+ updates = {k: v for k, v in body.model_dump().items() if v is not None}
526
+ if not updates:
527
+ raise HTTPException(status_code=400, detail={"success": False, "error": "No fields to update"})
528
+ link = service.update_link(short_code, owner_id, **updates)
529
+ return _build_link_response(link, service)
530
+ except (ValidationError, AuthenticationError) as e:
531
+ raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
532
+ except LinkNotFoundError as e:
533
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
534
+ except AuthorizationError as e:
535
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
536
+
537
+
538
+ @router.delete(
539
+ "/url-shortener/{short_code}",
540
+ summary="Deactivate a short link",
541
+ )
542
+ def deactivate_link(
543
+ short_code: str = Path(...),
544
+ owner_id: str = Depends(_auth_owner),
545
+ ):
546
+ service = _get_service()
547
+ try:
548
+ service.deactivate_link(short_code, owner_id)
549
+ return {"success": True, "message": f"Link '{short_code}' deactivated", "application_id": _app_id()}
550
+ except LinkNotFoundError as e:
551
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
552
+ except AuthorizationError as e:
553
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
554
+
555
+
556
+ @router.get(
557
+ "/url-shortener/{short_code}/stats",
558
+ response_model=StatsResponse,
559
+ summary="Get click statistics for a link (browsers, devices, referrers, etc.)",
560
+ )
561
+ def get_link_stats(
562
+ short_code: str = Path(...),
563
+ owner_id: str = Depends(_auth_owner),
564
+ ):
565
+ service = _get_service()
566
+ try:
567
+ stats = service.get_link_stats(short_code, owner_id)
568
+ return StatsResponse(
569
+ short_code=stats["short_code"],
570
+ long_url=stats["long_url"],
571
+ click_count=stats["click_count"],
572
+ created_at=stats["created_at"],
573
+ last_accessed_at=stats["last_accessed_at"],
574
+ is_active=stats["is_active"],
575
+ expires_at=stats["expires_at"],
576
+ max_clicks=stats["max_clicks"],
577
+ has_password=stats["has_password"],
578
+ utm=stats["utm"],
579
+ analytics=stats["analytics"],
580
+ campaign_id=stats.get("campaign_id"),
581
+ custom_domain=stats.get("custom_domain"),
582
+ fallback_url=stats.get("fallback_url"),
583
+ webhook_url=stats.get("webhook_url"),
584
+ application_id=_app_id(),
585
+ )
586
+ except LinkNotFoundError as e:
587
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
588
+ except AuthorizationError as e:
589
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
590
+
591
+
592
+ @router.get(
593
+ "/url-shortener/{short_code}/clicks",
594
+ response_model=ClickDetailsResponse,
595
+ summary="Get detailed click log for a link",
596
+ )
597
+ def get_click_details(
598
+ short_code: str = Path(...),
599
+ limit: int = Query(100, ge=1, le=1000),
600
+ owner_id: str = Depends(_auth_owner),
601
+ ):
602
+ service = _get_service()
603
+ try:
604
+ clicks = service.get_click_details(short_code, owner_id, limit)
605
+ return ClickDetailsResponse(clicks=[ClickDetail(**c) for c in clicks], application_id=_app_id())
606
+ except LinkNotFoundError as e:
607
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
608
+ except AuthorizationError as e:
609
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
610
+
611
+
612
+ @router.get(
613
+ "/url-shortener/{short_code}/qr",
614
+ response_model=QRCodeResponse,
615
+ summary="Get QR code for a short link",
616
+ )
617
+ def get_qr_code(
618
+ short_code: str = Path(...),
619
+ ):
620
+ service = _get_service()
621
+ try:
622
+ data = service.get_qr_code(short_code)
623
+ return QRCodeResponse(**data, application_id=_app_id())
624
+ except LinkNotFoundError as e:
625
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
626
+
627
+
628
+ @router.get(
629
+ "/url-shortener/{short_code}/health",
630
+ response_model=HealthCheckResponse,
631
+ summary="Check if the destination URL is reachable",
632
+ )
633
+ def link_health(
634
+ short_code: str = Path(...),
635
+ owner_id: str = Depends(_auth_owner),
636
+ ):
637
+ service = _get_service()
638
+ try:
639
+ result = service.check_link_health(short_code, owner_id)
640
+ return HealthCheckResponse(**result, application_id=_app_id())
641
+ except LinkNotFoundError as e:
642
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
643
+ except AuthorizationError as e:
644
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
645
+
646
+
647
+ @router.get(
648
+ "/url-shortener/{short_code}/clicks/export",
649
+ summary="Export click data as CSV",
650
+ )
651
+ def export_clicks_csv(
652
+ short_code: str = Path(...),
653
+ owner_id: str = Depends(_auth_owner),
654
+ ):
655
+ service = _get_service()
656
+ try:
657
+ csv_data = service.export_clicks(short_code, owner_id)
658
+ return PlainTextResponse(csv_data, media_type="text/csv",
659
+ headers={"Content-Disposition": f"attachment; filename=clicks_{short_code}.csv"})
660
+ except LinkNotFoundError as e:
661
+ raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
662
+ except AuthorizationError as e:
663
+ raise HTTPException(status_code=403, detail={"success": False, "error": str(e)})
app/services/url_shortener_service.py ADDED
@@ -0,0 +1,1198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import csv
5
+ import hashlib
6
+ import hmac
7
+ import io
8
+ import ipaddress
9
+ import json
10
+ import os
11
+ import secrets
12
+ import socket
13
+ import sqlite3
14
+ import string
15
+ import threading
16
+ import unicodedata
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+ from datetime import datetime, timedelta, timezone
20
+ from typing import Any, Dict, List, Optional
21
+ from urllib.parse import urlparse
22
+
23
+ from app.config import get_settings
24
+ from app.core.logger import get_logger
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ _Settings = get_settings()
29
+
30
+ _SHORTENER_SECRET = os.environ.get("URL_SHORTENER_SECRET", "")
31
+ if not _SHORTENER_SECRET or _SHORTENER_SECRET == "dev-secret-change-me":
32
+ logger.warning(
33
+ "URL_SHORTENER_SECRET is not set or is the default value. "
34
+ "Set it to a strong random secret before deploying."
35
+ )
36
+
37
+ SRV_BASE_URL = os.environ.get("URL_SHORTENER_BASE", "http://localhost:7860/api/v1")
38
+
39
+ PLAN_LIMITS: Dict[str, Dict[str, Any]] = {
40
+ "free": {"max_links": 50, "max_clicks_per_link": 1000, "custom_alias": False, "analytics_days": 7, "tags": False, "max_api_keys": 1, "campaigns": False, "webhooks": False, "custom_domain": False, "geo_targeting": False},
41
+ "pro": {"max_links": 5000, "max_clicks_per_link": 100000, "custom_alias": True, "analytics_days": 365, "tags": True, "max_api_keys": 5, "campaigns": True, "webhooks": False, "custom_domain": False, "geo_targeting": False},
42
+ "enterprise": {"max_links": 999999, "max_clicks_per_link": 99999999, "custom_alias": True, "analytics_days": 9999, "tags": True, "max_api_keys": 50, "campaigns": True, "webhooks": True, "custom_domain": True, "geo_targeting": True},
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class ShortLink:
48
+ short_code: str
49
+ long_url: str
50
+ owner_id: str
51
+ created_at: datetime
52
+ expires_at: Optional[datetime]
53
+ max_clicks: Optional[int]
54
+ click_count: int
55
+ is_active: bool
56
+ tags: List[str]
57
+ note: Optional[str]
58
+ password_hash: Optional[str] = None
59
+ utm_source: Optional[str] = None
60
+ utm_medium: Optional[str] = None
61
+ utm_campaign: Optional[str] = None
62
+ campaign_id: Optional[str] = None
63
+ custom_domain: Optional[str] = None
64
+ fallback_url: Optional[str] = None
65
+ webhook_url: Optional[str] = None
66
+ geo_targeting: Optional[Dict[str, Any]] = None
67
+
68
+
69
+ class URLShortenerError(Exception):
70
+ pass
71
+
72
+ class ValidationError(URLShortenerError):
73
+ pass
74
+
75
+ class AliasTakenError(URLShortenerError):
76
+ pass
77
+
78
+ class LinkNotFoundError(URLShortenerError):
79
+ pass
80
+
81
+ class LinkExpiredError(URLShortenerError):
82
+ pass
83
+
84
+ class AuthenticationError(URLShortenerError):
85
+ pass
86
+
87
+ class AuthorizationError(URLShortenerError):
88
+ pass
89
+
90
+ class PlanLimitExceededError(URLShortenerError):
91
+ pass
92
+
93
+
94
+ _ALPHABET = string.digits + string.ascii_lowercase + string.ascii_uppercase
95
+
96
+ _BLOCKED_DNS_WILDCARDS = (
97
+ "nip.io", "sslip.io", "xip.io", "localtest.me",
98
+ "lvh.me", "1u.ms", "traefik.me",
99
+ )
100
+
101
+
102
+ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
103
+ if ip.version == 6:
104
+ mapped = ip.ipv4_mapped
105
+ if mapped is not None:
106
+ if _is_blocked_ip(mapped):
107
+ return True
108
+ ip_int = int(ip)
109
+ if ip_int >> 32 == 0:
110
+ ipv4_part = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF)
111
+ if _is_blocked_ip(ipv4_part):
112
+ return True
113
+ return False
114
+ addr_int = int(ip)
115
+ if addr_int >> 24 == 0:
116
+ return True
117
+ return (
118
+ ip.is_private or ip.is_loopback or ip.is_link_local
119
+ or ip.is_reserved or ip.is_multicast
120
+ )
121
+
122
+
123
+ def _check_ip_string(hostname: str) -> bool:
124
+ try:
125
+ return _is_blocked_ip(ipaddress.ip_address(hostname))
126
+ except ValueError:
127
+ pass
128
+ if hostname.isdigit():
129
+ try:
130
+ return _is_blocked_ip(ipaddress.ip_address(int(hostname)))
131
+ except (ValueError, OverflowError):
132
+ pass
133
+ if hostname.lower().startswith("0x"):
134
+ try:
135
+ return _is_blocked_ip(ipaddress.ip_address(int(hostname, 16)))
136
+ except (ValueError, OverflowError):
137
+ pass
138
+ parts = hostname.split(".")
139
+ if all(p.lower().startswith("0x") for p in parts) and len(parts) in (2, 3, 4):
140
+ try:
141
+ numeric = sum(int(p, 16) << (8 * (len(parts) - 1 - i)) for i, p in enumerate(parts))
142
+ return _is_blocked_ip(ipaddress.ip_address(numeric))
143
+ except (ValueError, OverflowError):
144
+ pass
145
+ if 1 <= len(parts) <= 3 and all(p.isdigit() for p in parts):
146
+ expanded = parts + ["0"] * (4 - len(parts))
147
+ try:
148
+ return _is_blocked_ip(ipaddress.ip_address(".".join(expanded)))
149
+ except ValueError:
150
+ pass
151
+ if len(parts) == 4:
152
+ for p in parts:
153
+ if len(p) > 1 and p[0] == "0" and p.isdigit():
154
+ return True
155
+ return False
156
+
157
+
158
+ def _normalize_hostname(hostname: str) -> str:
159
+ return unicodedata.normalize("NFKC", hostname).lower()
160
+
161
+
162
+ def _has_dns_wildcard_suffix(hostname: str, suffixes: tuple) -> bool:
163
+ hostname = _normalize_hostname(hostname)
164
+ for suffix in suffixes:
165
+ if hostname == suffix or hostname.endswith("." + suffix):
166
+ return True
167
+ return False
168
+
169
+
170
+ def validate_url(url: str, max_length: int = 2048) -> None:
171
+ if not url or not isinstance(url, str):
172
+ raise ValidationError("URL must be a non-empty string")
173
+ stripped = url.strip()
174
+ if stripped != url:
175
+ raise ValidationError("URL must not contain leading or trailing whitespace")
176
+ if len(url) > max_length:
177
+ raise ValidationError(f"URL exceeds max length of {max_length}")
178
+ if "@" in url:
179
+ raise ValidationError("URL must not contain userinfo ('@')")
180
+ parsed = urlparse(url)
181
+ if parsed.scheme not in ("http", "https"):
182
+ raise ValidationError("Only http and https schemes are allowed")
183
+ hostname = _normalize_hostname(parsed.hostname or "")
184
+ if not hostname:
185
+ raise ValidationError("URL must include a valid host")
186
+ if hostname == "localhost":
187
+ raise ValidationError("URLs pointing at localhost are not allowed")
188
+ if _has_dns_wildcard_suffix(hostname, _BLOCKED_DNS_WILDCARDS):
189
+ raise ValidationError(f"URL uses a blocked DNS wildcard service ({hostname})")
190
+ if _check_ip_string(hostname):
191
+ raise ValidationError("URLs pointing at private/internal addresses are not allowed")
192
+ try:
193
+ addrinfo = socket.getaddrinfo(hostname, None)
194
+ except (socket.gaierror, OSError):
195
+ addrinfo = ()
196
+ for family, _, _, _, sockaddr in addrinfo:
197
+ ip_str = sockaddr[0]
198
+ try:
199
+ ip = ipaddress.ip_address(ip_str)
200
+ if _is_blocked_ip(ip):
201
+ raise ValidationError(f"URL resolves to a private/internal address ({ip_str})")
202
+ except ValueError:
203
+ continue
204
+
205
+
206
+ def validate_alias(alias: str) -> None:
207
+ if not (3 <= len(alias) <= 32):
208
+ raise ValidationError("Custom alias must be 3-32 characters")
209
+ allowed = set(string.ascii_letters + string.digits + "-_")
210
+ if not set(alias) <= allowed:
211
+ raise ValidationError("Custom alias may only contain letters, digits, '-' and '_'")
212
+
213
+
214
+ def generate_short_code(length: int) -> str:
215
+ if length < 1:
216
+ raise ValueError("Short code length must be at least 1")
217
+ return "".join(secrets.choice(_ALPHABET) for _ in range(length))
218
+
219
+
220
+ def _now_iso() -> str:
221
+ return datetime.now(timezone.utc).isoformat()
222
+
223
+
224
+ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
225
+ return datetime.fromisoformat(value) if value else None
226
+
227
+
228
+ def _hash_key(api_key: str) -> str:
229
+ secret = os.environ.get("URL_SHORTENER_SECRET", "dev-secret-change-me").encode()
230
+ return hmac.new(secret, api_key.encode(), hashlib.sha256).hexdigest()
231
+
232
+
233
+ def _hash_password(password: str) -> str:
234
+ return hashlib.sha256(password.encode()).hexdigest()
235
+
236
+
237
+ def _parse_user_agent(ua: str) -> Dict[str, str]:
238
+ ua_lower = ua.lower()
239
+ result: Dict[str, str] = {"browser": "Unknown", "device": "Desktop", "os": "Unknown"}
240
+ if "mobile" in ua_lower or "android" in ua_lower or "iphone" in ua_lower:
241
+ result["device"] = "Mobile"
242
+ if "tablet" in ua_lower or "ipad" in ua_lower:
243
+ result["device"] = "Tablet"
244
+ if "chrome" in ua_lower and "edge" not in ua_lower:
245
+ result["browser"] = "Chrome"
246
+ elif "firefox" in ua_lower:
247
+ result["browser"] = "Firefox"
248
+ elif "safari" in ua_lower and "chrome" not in ua_lower:
249
+ result["browser"] = "Safari"
250
+ elif "edge" in ua_lower:
251
+ result["browser"] = "Edge"
252
+ elif "msie" in ua_lower or "trident" in ua_lower:
253
+ result["browser"] = "Internet Explorer"
254
+ if "windows" in ua_lower:
255
+ result["os"] = "Windows"
256
+ elif "mac" in ua_lower:
257
+ result["os"] = "macOS"
258
+ elif "linux" in ua_lower:
259
+ result["os"] = "Linux"
260
+ elif "android" in ua_lower:
261
+ result["os"] = "Android"
262
+ elif "ios" in ua_lower or "iphone" in ua_lower:
263
+ result["os"] = "iOS"
264
+ return result
265
+
266
+
267
+ def _build_qr_data_url(short_code: str) -> str:
268
+ short_url = f"{SRV_BASE_URL}/url-shortener/{short_code}"
269
+ return f"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={short_url}"
270
+
271
+
272
+ def _apply_geo_targeting(rules: Dict[str, Any], default_url: str,
273
+ ip_address: Optional[str] = None,
274
+ user_agent: Optional[str] = None) -> str:
275
+ countries = rules.get("countries", {})
276
+ devices = rules.get("devices", {})
277
+ if not countries and not devices:
278
+ return default_url
279
+ ua_lower = (user_agent or "").lower()
280
+ is_mobile = "mobile" in ua_lower or "android" in ua_lower or "iphone" in ua_lower
281
+ is_tablet = "tablet" in ua_lower or "ipad" in ua_lower
282
+ # Device rule takes precedence
283
+ if is_tablet and "tablet" in devices:
284
+ return devices["tablet"]
285
+ if is_mobile and "mobile" in devices:
286
+ return devices["mobile"]
287
+ if not (is_mobile or is_tablet) and "desktop" in devices:
288
+ return devices["desktop"]
289
+ # Country-specific override (requires IP geolocation service)
290
+ # For now, limited to simplistic check
291
+ return default_url
292
+
293
+
294
+ def _fire_click_webhook(webhook_url: str, short_code: str, long_url: str,
295
+ referrer: Optional[str], user_agent: Optional[str],
296
+ ip_address: Optional[str]) -> None:
297
+ payload = {
298
+ "event": "link_clicked",
299
+ "short_code": short_code,
300
+ "long_url": long_url,
301
+ "referrer": referrer,
302
+ "user_agent": user_agent,
303
+ "ip_address": ip_address,
304
+ "timestamp": _now_iso(),
305
+ }
306
+ try:
307
+ import httpx
308
+ with httpx.Client(timeout=5.0) as client:
309
+ client.post(webhook_url, json=payload)
310
+ except ImportError:
311
+ import urllib.request
312
+ import json as _json
313
+ data = _json.dumps(payload).encode()
314
+ req = urllib.request.Request(webhook_url, data=data,
315
+ headers={"Content-Type": "application/json"},
316
+ method="POST")
317
+ urllib.request.urlopen(req, timeout=5)
318
+
319
+
320
+ class Storage:
321
+ def __init__(self, db_path: str):
322
+ self._db_path = db_path
323
+ self._lock = threading.RLock()
324
+ self._local = threading.local()
325
+ self._init_schema()
326
+
327
+ @property
328
+ def _conn(self) -> sqlite3.Connection:
329
+ if not hasattr(self._local, "conn"):
330
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
331
+ conn.row_factory = sqlite3.Row
332
+ conn.execute("PRAGMA foreign_keys = ON")
333
+ conn.execute("PRAGMA journal_mode = WAL")
334
+ self._local.conn = conn
335
+ return self._local.conn
336
+
337
+ @contextmanager
338
+ def writer_lock(self):
339
+ with self._lock:
340
+ yield
341
+
342
+ def _init_schema(self) -> None:
343
+ with self._lock, self._conn as conn:
344
+ conn.execute("""
345
+ CREATE TABLE IF NOT EXISTS url_shortener_owners (
346
+ owner_id TEXT PRIMARY KEY,
347
+ name TEXT NOT NULL,
348
+ api_key_hash TEXT NOT NULL UNIQUE,
349
+ plan TEXT NOT NULL DEFAULT 'free',
350
+ created_at TEXT NOT NULL
351
+ )
352
+ """)
353
+ conn.execute("""
354
+ CREATE TABLE IF NOT EXISTS url_shortener_links (
355
+ short_code TEXT PRIMARY KEY,
356
+ long_url TEXT NOT NULL,
357
+ owner_id TEXT NOT NULL,
358
+ created_at TEXT NOT NULL,
359
+ expires_at TEXT,
360
+ max_clicks INTEGER,
361
+ click_count INTEGER NOT NULL DEFAULT 0,
362
+ last_accessed_at TEXT,
363
+ is_active INTEGER NOT NULL DEFAULT 1,
364
+ tags TEXT,
365
+ note TEXT,
366
+ password_hash TEXT,
367
+ utm_source TEXT,
368
+ utm_medium TEXT,
369
+ utm_campaign TEXT,
370
+ campaign_id TEXT,
371
+ custom_domain TEXT,
372
+ fallback_url TEXT,
373
+ webhook_url TEXT,
374
+ geo_targeting TEXT,
375
+ FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
376
+ )
377
+ """)
378
+ conn.execute("""
379
+ CREATE TABLE IF NOT EXISTS url_shortener_clicks (
380
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
381
+ short_code TEXT NOT NULL,
382
+ referrer TEXT,
383
+ user_agent TEXT,
384
+ ip_address TEXT,
385
+ country TEXT,
386
+ browser TEXT,
387
+ device TEXT,
388
+ os TEXT,
389
+ clicked_at TEXT NOT NULL,
390
+ FOREIGN KEY (short_code) REFERENCES url_shortener_links(short_code)
391
+ )
392
+ """)
393
+ conn.execute("""
394
+ CREATE TABLE IF NOT EXISTS url_shortener_audit_log (
395
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
396
+ timestamp TEXT NOT NULL,
397
+ owner_id TEXT,
398
+ action TEXT NOT NULL,
399
+ short_code TEXT,
400
+ detail TEXT
401
+ )
402
+ """)
403
+ conn.execute("""
404
+ CREATE TABLE IF NOT EXISTS url_shortener_campaigns (
405
+ campaign_id TEXT PRIMARY KEY,
406
+ owner_id TEXT NOT NULL,
407
+ name TEXT NOT NULL,
408
+ description TEXT,
409
+ created_at TEXT NOT NULL,
410
+ is_active INTEGER NOT NULL DEFAULT 1,
411
+ FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
412
+ )
413
+ """)
414
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_us_links_owner ON url_shortener_links(owner_id)")
415
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_us_clicks_code ON url_shortener_clicks(short_code)")
416
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_us_clicks_at ON url_shortener_clicks(clicked_at)")
417
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_us_links_campaign ON url_shortener_links(campaign_id)")
418
+ self._migrate_schema(conn)
419
+
420
+ def _migrate_schema(self, conn: sqlite3.Connection) -> None:
421
+ existing = {r["name"] for r in conn.execute("PRAGMA table_info(url_shortener_links)").fetchall()}
422
+ migrations = {
423
+ "campaign_id": "ALTER TABLE url_shortener_links ADD COLUMN campaign_id TEXT",
424
+ "custom_domain": "ALTER TABLE url_shortener_links ADD COLUMN custom_domain TEXT",
425
+ "fallback_url": "ALTER TABLE url_shortener_links ADD COLUMN fallback_url TEXT",
426
+ "webhook_url": "ALTER TABLE url_shortener_links ADD COLUMN webhook_url TEXT",
427
+ "geo_targeting": "ALTER TABLE url_shortener_links ADD COLUMN geo_targeting TEXT",
428
+ }
429
+ for col, ddl in migrations.items():
430
+ if col not in existing:
431
+ conn.execute(ddl)
432
+
433
+ def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free") -> None:
434
+ with self._lock, self._conn as conn:
435
+ conn.execute(
436
+ "INSERT INTO url_shortener_owners (owner_id, name, api_key_hash, plan, created_at) "
437
+ "VALUES (?, ?, ?, ?, ?)",
438
+ (owner_id, name, api_key_hash, plan, _now_iso()),
439
+ )
440
+
441
+ def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[sqlite3.Row]:
442
+ return self._conn.execute(
443
+ "SELECT * FROM url_shortener_owners WHERE api_key_hash = ?", (api_key_hash,)
444
+ ).fetchone()
445
+
446
+ def get_owner(self, owner_id: str) -> Optional[sqlite3.Row]:
447
+ return self._conn.execute(
448
+ "SELECT * FROM url_shortener_owners WHERE owner_id = ?", (owner_id,)
449
+ ).fetchone()
450
+
451
+ def update_owner_plan(self, owner_id: str, plan: str) -> None:
452
+ with self._lock, self._conn as conn:
453
+ conn.execute("UPDATE url_shortener_owners SET plan = ? WHERE owner_id = ?", (plan, owner_id))
454
+
455
+ def code_exists(self, short_code: str) -> bool:
456
+ return self._conn.execute(
457
+ "SELECT 1 FROM url_shortener_links WHERE short_code = ?", (short_code,)
458
+ ).fetchone() is not None
459
+
460
+ def insert_link(self, **kwargs: Any) -> None:
461
+ with self._lock, self._conn as conn:
462
+ conn.execute("""
463
+ INSERT INTO url_shortener_links
464
+ (short_code, long_url, owner_id, created_at, expires_at,
465
+ max_clicks, click_count, last_accessed_at, is_active, tags, note,
466
+ password_hash, utm_source, utm_medium, utm_campaign,
467
+ campaign_id, custom_domain, fallback_url, webhook_url, geo_targeting)
468
+ VALUES
469
+ (:short_code, :long_url, :owner_id, :created_at, :expires_at,
470
+ :max_clicks, 0, NULL, 1, :tags, :note,
471
+ :password_hash, :utm_source, :utm_medium, :utm_campaign,
472
+ :campaign_id, :custom_domain, :fallback_url, :webhook_url,
473
+ :geo_targeting)
474
+ """, kwargs)
475
+
476
+ def update_link(self, short_code: str, **updates: Any) -> None:
477
+ with self._lock, self._conn as conn:
478
+ sets = ", ".join(f"{k} = ?" for k in updates)
479
+ vals = list(updates.values()) + [short_code]
480
+ conn.execute(f"UPDATE url_shortener_links SET {sets} WHERE short_code = ?", vals)
481
+
482
+ def get_link(self, short_code: str) -> Optional[sqlite3.Row]:
483
+ return self._conn.execute(
484
+ "SELECT * FROM url_shortener_links WHERE short_code = ?", (short_code,)
485
+ ).fetchone()
486
+
487
+ def list_links_for_owner(self, owner_id: str) -> List[sqlite3.Row]:
488
+ return self._conn.execute(
489
+ "SELECT * FROM url_shortener_links WHERE owner_id = ? ORDER BY created_at DESC",
490
+ (owner_id,),
491
+ ).fetchall()
492
+
493
+ def deactivate_link(self, short_code: str) -> None:
494
+ with self._lock, self._conn as conn:
495
+ conn.execute("UPDATE url_shortener_links SET is_active = 0 WHERE short_code = ?", (short_code,))
496
+
497
+ def record_click(self, short_code: str, referrer: Optional[str] = None,
498
+ user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> None:
499
+ now = _now_iso()
500
+ parsed = _parse_user_agent(user_agent or "")
501
+ with self._lock, self._conn as conn:
502
+ conn.execute(
503
+ "INSERT INTO url_shortener_clicks "
504
+ "(short_code, referrer, user_agent, ip_address, country, browser, device, os, clicked_at) "
505
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
506
+ (short_code, referrer, user_agent, ip_address, None,
507
+ parsed["browser"], parsed["device"], parsed["os"], now),
508
+ )
509
+ conn.execute(
510
+ "UPDATE url_shortener_links SET click_count = click_count + 1, last_accessed_at = ? "
511
+ "WHERE short_code = ?",
512
+ (now, short_code),
513
+ )
514
+
515
+ def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
516
+ total = self._conn.execute(
517
+ "SELECT COUNT(*) FROM url_shortener_clicks WHERE short_code = ?", (short_code,)
518
+ ).fetchone()[0]
519
+ browsers = dict(self._conn.execute(
520
+ "SELECT browser, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
521
+ "AND browser IS NOT NULL GROUP BY browser ORDER BY COUNT(*) DESC",
522
+ (short_code,)
523
+ ).fetchall())
524
+ devices = dict(self._conn.execute(
525
+ "SELECT device, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
526
+ "AND device IS NOT NULL GROUP BY device ORDER BY COUNT(*) DESC",
527
+ (short_code,)
528
+ ).fetchall())
529
+ referrers = dict(self._conn.execute(
530
+ "SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS ref, COUNT(*) AS n "
531
+ "FROM url_shortener_clicks WHERE short_code = ? "
532
+ "GROUP BY ref ORDER BY n DESC LIMIT 10",
533
+ (short_code,)
534
+ ).fetchall())
535
+ os_data = dict(self._conn.execute(
536
+ "SELECT os, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
537
+ "AND os IS NOT NULL GROUP BY os ORDER BY COUNT(*) DESC",
538
+ (short_code,)
539
+ ).fetchall())
540
+ recent = self._conn.execute(
541
+ "SELECT clicked_at FROM url_shortener_clicks WHERE short_code = ? "
542
+ "ORDER BY clicked_at DESC LIMIT 50",
543
+ (short_code,)
544
+ ).fetchall()
545
+ return {
546
+ "total_clicks": total,
547
+ "browsers": browsers,
548
+ "devices": devices,
549
+ "operating_systems": os_data,
550
+ "top_referrers": referrers,
551
+ "recent_clicks": [r["clicked_at"] for r in recent],
552
+ }
553
+
554
+ def get_link_count_for_owner(self, owner_id: str) -> int:
555
+ return self._conn.execute(
556
+ "SELECT COUNT(*) FROM url_shortener_links WHERE owner_id = ?", (owner_id,)
557
+ ).fetchone()[0]
558
+
559
+ def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = "") -> None:
560
+ with self._lock, self._conn as conn:
561
+ conn.execute(
562
+ "INSERT INTO url_shortener_audit_log (timestamp, owner_id, action, short_code, detail) "
563
+ "VALUES (?, ?, ?, ?, ?)",
564
+ (_now_iso(), owner_id, action, short_code, detail),
565
+ )
566
+
567
+ def export_links_csv(self, owner_id: str) -> str:
568
+ rows = self._conn.execute(
569
+ "SELECT short_code, long_url, created_at, click_count, is_active, tags, note "
570
+ "FROM url_shortener_links WHERE owner_id = ? ORDER BY created_at DESC", (owner_id,)
571
+ ).fetchall()
572
+ buf = io.StringIO()
573
+ w = csv.writer(buf)
574
+ w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
575
+ for r in rows:
576
+ w.writerow([r["short_code"], r["long_url"], r["created_at"],
577
+ r["click_count"], r["is_active"], r["tags"] or "", r["note"] or ""])
578
+ return buf.getvalue()
579
+
580
+ def export_clicks_csv(self, short_code: str) -> str:
581
+ rows = self._conn.execute(
582
+ "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
583
+ "FROM url_shortener_clicks WHERE short_code = ? ORDER BY clicked_at DESC", (short_code,)
584
+ ).fetchall()
585
+ buf = io.StringIO()
586
+ w = csv.writer(buf)
587
+ w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
588
+ for r in rows:
589
+ w.writerow([r["id"], r["referrer"], r["user_agent"], r["ip_address"],
590
+ r["browser"], r["device"], r["os"], r["clicked_at"]])
591
+ return buf.getvalue()
592
+
593
+ def list_links_by_campaign(self, campaign_id: str) -> List[sqlite3.Row]:
594
+ return self._conn.execute(
595
+ "SELECT * FROM url_shortener_links WHERE campaign_id = ? ORDER BY created_at DESC",
596
+ (campaign_id,),
597
+ ).fetchall()
598
+
599
+ def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None) -> None:
600
+ with self._lock, self._conn as conn:
601
+ conn.execute(
602
+ "INSERT INTO url_shortener_campaigns (campaign_id, owner_id, name, description, created_at) "
603
+ "VALUES (?, ?, ?, ?, ?)",
604
+ (campaign_id, owner_id, name, description, _now_iso()),
605
+ )
606
+
607
+ def get_campaign(self, campaign_id: str) -> Optional[sqlite3.Row]:
608
+ return self._conn.execute(
609
+ "SELECT * FROM url_shortener_campaigns WHERE campaign_id = ?", (campaign_id,)
610
+ ).fetchone()
611
+
612
+ def list_campaigns_for_owner(self, owner_id: str) -> List[sqlite3.Row]:
613
+ return self._conn.execute(
614
+ "SELECT * FROM url_shortener_campaigns WHERE owner_id = ? ORDER BY created_at DESC",
615
+ (owner_id,),
616
+ ).fetchall()
617
+
618
+ def deactivate_campaign(self, campaign_id: str) -> None:
619
+ with self._lock, self._conn as conn:
620
+ conn.execute(
621
+ "UPDATE url_shortener_campaigns SET is_active = 0 WHERE campaign_id = ?",
622
+ (campaign_id,),
623
+ )
624
+
625
+ def get_campaign_link_count(self, campaign_id: str) -> int:
626
+ return self._conn.execute(
627
+ "SELECT COUNT(*) FROM url_shortener_links WHERE campaign_id = ?", (campaign_id,)
628
+ ).fetchone()[0]
629
+
630
+ def get_campaign_total_clicks(self, campaign_id: str) -> int:
631
+ return self._conn.execute(
632
+ "SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE campaign_id = ?",
633
+ (campaign_id,),
634
+ ).fetchone()[0]
635
+
636
+ def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
637
+ codes = self._conn.execute(
638
+ "SELECT short_code FROM url_shortener_links WHERE campaign_id = ?", (campaign_id,)
639
+ ).fetchall()
640
+ total = 0
641
+ browsers: Dict[str, int] = {}
642
+ devices: Dict[str, int] = {}
643
+ os_data: Dict[str, int] = {}
644
+ for (code,) in codes:
645
+ total += self._conn.execute(
646
+ "SELECT COUNT(*) FROM url_shortener_clicks WHERE short_code = ?", (code,)
647
+ ).fetchone()[0]
648
+ for b, n in self._conn.execute(
649
+ "SELECT browser, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
650
+ "AND browser IS NOT NULL GROUP BY browser", (code,)
651
+ ).fetchall():
652
+ browsers[b] = browsers.get(b, 0) + n
653
+ for d, n in self._conn.execute(
654
+ "SELECT device, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
655
+ "AND device IS NOT NULL GROUP BY device", (code,)
656
+ ).fetchall():
657
+ devices[d] = devices.get(d, 0) + n
658
+ for o, n in self._conn.execute(
659
+ "SELECT os, COUNT(*) FROM url_shortener_clicks WHERE short_code = ? "
660
+ "AND os IS NOT NULL GROUP BY os", (code,)
661
+ ).fetchall():
662
+ os_data[o] = os_data.get(o, 0) + n
663
+ return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
664
+
665
+ def owner_summary(self, owner_id: str) -> Dict[str, Any]:
666
+ row = self.get_owner(owner_id)
667
+ if row is None:
668
+ return {}
669
+ link_count = self.get_link_count_for_owner(owner_id)
670
+ total_clicks = self._conn.execute(
671
+ "SELECT COALESCE(SUM(click_count), 0) FROM url_shortener_links WHERE owner_id = ?",
672
+ (owner_id,)
673
+ ).fetchone()[0]
674
+ active = self._conn.execute(
675
+ "SELECT COUNT(*) FROM url_shortener_links WHERE owner_id = ? AND is_active = 1",
676
+ (owner_id,)
677
+ ).fetchone()[0]
678
+ return {
679
+ "owner_id": owner_id,
680
+ "name": row["name"],
681
+ "plan": row["plan"],
682
+ "total_links": link_count,
683
+ "active_links": active,
684
+ "total_clicks": total_clicks,
685
+ "created_at": row["created_at"],
686
+ }
687
+
688
+
689
+ class URLShortenerService:
690
+ def __init__(self, db_path: Optional[str] = None):
691
+ self.db_path = db_path or os.environ.get("URL_SHORTENER_DB", "data/url_shortener.db")
692
+ self.storage = Storage(self.db_path)
693
+ logger.info("URLShortenerService initialized (db=%s)", self.db_path)
694
+
695
+ def close(self) -> None:
696
+ self.storage.close()
697
+
698
+ def _get_owner_plan(self, owner_id: str) -> Dict[str, Any]:
699
+ row = self.storage.get_owner(owner_id)
700
+ if row is None:
701
+ raise AuthenticationError(f"Owner '{owner_id}' does not exist")
702
+ return PLAN_LIMITS.get(row["plan"], PLAN_LIMITS["free"])
703
+
704
+ def create_owner(self, name: str, plan: str = "free") -> Dict[str, str]:
705
+ if plan not in PLAN_LIMITS:
706
+ plan = "free"
707
+ owner_id = secrets.token_hex(8)
708
+ api_key = f"sk_{secrets.token_urlsafe(24)}"
709
+ self.storage.create_owner(owner_id, name, _hash_key(api_key), plan)
710
+ self.storage.audit(owner_id, "CREATE_OWNER", None, f"plan={plan}")
711
+ logger.info("Created owner '%s' (%s) plan=%s", name, owner_id, plan)
712
+ return {"owner_id": owner_id, "api_key": api_key}
713
+
714
+ def authenticate(self, api_key: str) -> str:
715
+ row = self.storage.get_owner_by_key_hash(_hash_key(api_key))
716
+ if row is None:
717
+ raise AuthenticationError("Invalid API key")
718
+ return row["owner_id"]
719
+
720
+ def update_owner_plan(self, owner_id: str, plan: str) -> Dict[str, Any]:
721
+ if plan not in PLAN_LIMITS:
722
+ raise ValidationError(f"Invalid plan '{plan}'. Must be one of: {', '.join(PLAN_LIMITS)}")
723
+ row = self.storage.get_owner(owner_id)
724
+ if row is None:
725
+ raise AuthenticationError(f"Owner '{owner_id}' does not exist")
726
+ self.storage.update_owner_plan(owner_id, plan)
727
+ self.storage.audit(owner_id, "UPDATE_PLAN", None, f"new_plan={plan}")
728
+ logger.info("Owner %s plan updated to %s", owner_id, plan)
729
+ return {"owner_id": owner_id, "plan": plan}
730
+
731
+ def shorten(
732
+ self,
733
+ long_url: str,
734
+ owner_id: str,
735
+ custom_alias: Optional[str] = None,
736
+ expires_in_days: Optional[int] = None,
737
+ expires_at: Optional[datetime] = None,
738
+ max_clicks: Optional[int] = None,
739
+ tags: Optional[List[str]] = None,
740
+ note: Optional[str] = None,
741
+ password: Optional[str] = None,
742
+ utm_source: Optional[str] = None,
743
+ utm_medium: Optional[str] = None,
744
+ utm_campaign: Optional[str] = None,
745
+ campaign_id: Optional[str] = None,
746
+ custom_domain: Optional[str] = None,
747
+ fallback_url: Optional[str] = None,
748
+ webhook_url: Optional[str] = None,
749
+ geo_targeting: Optional[Dict[str, Any]] = None,
750
+ ) -> ShortLink:
751
+ plan_limits = self._get_owner_plan(owner_id)
752
+
753
+ if custom_alias is not None:
754
+ if not plan_limits["custom_alias"]:
755
+ raise PlanLimitExceededError("Your plan does not support custom aliases. Upgrade to Pro.")
756
+ validate_alias(custom_alias)
757
+
758
+ if max_clicks is not None and max_clicks < 1:
759
+ raise ValidationError("max_clicks must be a positive integer")
760
+
761
+ if expires_in_days is not None and expires_in_days < 1:
762
+ raise ValidationError("expires_in_days must be a positive integer")
763
+
764
+ if tags and not plan_limits["tags"]:
765
+ raise PlanLimitExceededError("Your plan does not support tags. Upgrade to Pro.")
766
+
767
+ validate_url(long_url)
768
+
769
+ current_count = self.storage.get_link_count_for_owner(owner_id)
770
+ if current_count >= plan_limits["max_links"]:
771
+ raise PlanLimitExceededError(
772
+ f"You have reached the limit of {plan_limits['max_links']} links on your plan. "
773
+ "Upgrade to create more."
774
+ )
775
+
776
+ if expires_at is None and expires_in_days is not None:
777
+ expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
778
+
779
+ if tags is not None:
780
+ if len(tags) > 20:
781
+ raise ValidationError("At most 20 tags are allowed")
782
+ for tag in tags:
783
+ if not tag or len(tag) > 50:
784
+ raise ValidationError("Each tag must be 1-50 characters")
785
+ if "," in tag:
786
+ raise ValidationError("Tags must not contain commas")
787
+
788
+ if note is not None and len(note) > 2000:
789
+ raise ValidationError("note must not exceed 2000 characters")
790
+
791
+ if password is not None and len(password) > 128:
792
+ raise ValidationError("password must not exceed 128 characters")
793
+
794
+ if webhook_url and not plan_limits["webhooks"]:
795
+ raise PlanLimitExceededError("Your plan does not support webhooks. Upgrade to Enterprise.")
796
+ if webhook_url:
797
+ validate_url(webhook_url)
798
+
799
+ if custom_domain and not plan_limits["custom_domain"]:
800
+ raise PlanLimitExceededError("Your plan does not support custom domains. Upgrade to Enterprise.")
801
+
802
+ if geo_targeting and not plan_limits["geo_targeting"]:
803
+ raise PlanLimitExceededError("Your plan does not support geo-targeting. Upgrade to Enterprise.")
804
+
805
+ if campaign_id:
806
+ camp = self.storage.get_campaign(campaign_id)
807
+ if camp is None:
808
+ raise ValidationError(f"Campaign '{campaign_id}' not found")
809
+ if camp["owner_id"] != owner_id:
810
+ raise AuthorizationError("Campaign does not belong to this owner")
811
+
812
+ if fallback_url:
813
+ validate_url(fallback_url)
814
+
815
+ with self.storage.writer_lock():
816
+ code = custom_alias
817
+ if code is not None:
818
+ if self.storage.code_exists(code):
819
+ raise AliasTakenError(f"Alias '{code}' is already in use")
820
+ else:
821
+ for _ in range(5):
822
+ candidate = generate_short_code(7)
823
+ if not self.storage.code_exists(candidate):
824
+ code = candidate
825
+ break
826
+ else:
827
+ raise URLShortenerError("Could not generate a unique short code, try again")
828
+
829
+ self.storage.insert_link(
830
+ short_code=code,
831
+ long_url=long_url,
832
+ owner_id=owner_id,
833
+ created_at=_now_iso(),
834
+ expires_at=expires_at.isoformat() if expires_at else None,
835
+ max_clicks=max_clicks,
836
+ tags=",".join(tags) if tags else None,
837
+ note=note,
838
+ password_hash=_hash_password(password) if password else None,
839
+ utm_source=utm_source,
840
+ utm_medium=utm_medium,
841
+ utm_campaign=utm_campaign,
842
+ campaign_id=campaign_id,
843
+ custom_domain=custom_domain,
844
+ fallback_url=fallback_url,
845
+ webhook_url=webhook_url,
846
+ geo_targeting=json.dumps(geo_targeting) if geo_targeting else None,
847
+ )
848
+
849
+ self.storage.audit(owner_id, "CREATE_LINK", code, long_url)
850
+ logger.info("Owner %s created short link '%s'", owner_id, code)
851
+ return self._row_to_link(self.storage.get_link(code))
852
+
853
+ def bulk_shorten(self, owner_id: str, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
854
+ results = []
855
+ for i, req in enumerate(requests):
856
+ try:
857
+ link = self.shorten(owner_id=owner_id, **req)
858
+ results.append({"index": i, "ok": True, "short_code": link.short_code, "long_url": link.long_url})
859
+ except URLShortenerError as e:
860
+ results.append({"index": i, "ok": False, "error": str(e)})
861
+ return results
862
+
863
+ def resolve(self, short_code: str, referrer: Optional[str] = None,
864
+ user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
865
+ with self.storage.writer_lock():
866
+ row = self.storage.get_link(short_code)
867
+ if row is None:
868
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
869
+
870
+ if row["password_hash"]:
871
+ raise LinkExpiredError(f"Link '{short_code}' is password-protected. Provide password via X-Link-Password header.")
872
+
873
+ fallback = row["fallback_url"]
874
+ if not row["is_active"]:
875
+ if fallback:
876
+ return fallback
877
+ raise LinkExpiredError(f"Link '{short_code}' has been deactivated")
878
+
879
+ expires_at = _parse_iso(row["expires_at"])
880
+ if expires_at and datetime.now(timezone.utc) > expires_at:
881
+ if fallback:
882
+ return fallback
883
+ raise LinkExpiredError(f"Link '{short_code}' expired on {expires_at.isoformat()}")
884
+
885
+ if row["max_clicks"] is not None and row["click_count"] >= row["max_clicks"]:
886
+ if fallback:
887
+ return fallback
888
+ raise LinkExpiredError(f"Link '{short_code}' has reached its click limit")
889
+
890
+ self.storage.record_click(short_code, referrer, user_agent, ip_address)
891
+ long_url = row["long_url"]
892
+
893
+ # Geo-targeting: override destination based on geo/device rules
894
+ geo_raw = row["geo_targeting"]
895
+ if isinstance(geo_raw, str) and geo_raw:
896
+ try:
897
+ rules = json.loads(geo_raw)
898
+ long_url = _apply_geo_targeting(rules, long_url, ip_address, user_agent)
899
+ except (json.JSONDecodeError, Exception):
900
+ pass
901
+
902
+ if row["utm_source"] or row["utm_medium"] or row["utm_campaign"]:
903
+ parsed = urlparse(long_url)
904
+ existing = dict(urlparse.parse_qsl(parsed.query))
905
+ if row["utm_source"]:
906
+ existing["utm_source"] = row["utm_source"]
907
+ if row["utm_medium"]:
908
+ existing["utm_medium"] = row["utm_medium"]
909
+ if row["utm_campaign"]:
910
+ existing["utm_campaign"] = row["utm_campaign"]
911
+ new_qs = "&".join(f"{k}={v}" for k, v in existing.items())
912
+ long_url = parsed._replace(query=new_qs).geturl()
913
+
914
+ # Fire webhook asynchronously outside the lock
915
+ webhook = row["webhook_url"]
916
+ if webhook:
917
+ try:
918
+ _fire_click_webhook(webhook, short_code, row["long_url"], referrer, user_agent, ip_address)
919
+ except Exception:
920
+ logger.exception("Failed to fire webhook for %s", short_code)
921
+
922
+ logger.info("Resolved '%s'", short_code)
923
+ return long_url
924
+
925
+ def resolve_with_password(self, short_code: str, password: str, referrer: Optional[str] = None,
926
+ user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> str:
927
+ with self.storage.writer_lock():
928
+ row = self.storage.get_link(short_code)
929
+ if row is None:
930
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
931
+ if not row["password_hash"]:
932
+ return self.resolve(short_code, referrer, user_agent, ip_address)
933
+ if _hash_password(password) != row["password_hash"]:
934
+ raise AuthorizationError("Incorrect password")
935
+ if not row["is_active"]:
936
+ raise LinkExpiredError(f"Link '{short_code}' has been deactivated")
937
+ expires_at = _parse_iso(row["expires_at"])
938
+ if expires_at and datetime.now(timezone.utc) > expires_at:
939
+ raise LinkExpiredError(f"Link '{short_code}' expired on {expires_at.isoformat()}")
940
+ if row["max_clicks"] is not None and row["click_count"] >= row["max_clicks"]:
941
+ raise LinkExpiredError(f"Link '{short_code}' has reached its click limit")
942
+ self.storage.record_click(short_code, referrer, user_agent, ip_address)
943
+ return row["long_url"]
944
+
945
+ def list_links(self, owner_id: str) -> List[ShortLink]:
946
+ return [self._row_to_link(r) for r in self.storage.list_links_for_owner(owner_id)]
947
+
948
+ def update_link(self, short_code: str, owner_id: str, **updates: Any) -> ShortLink:
949
+ row = self.storage.get_link(short_code)
950
+ if row is None:
951
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
952
+ if row["owner_id"] != owner_id:
953
+ raise AuthorizationError("You do not own this link")
954
+
955
+ safe_updates: Dict[str, Any] = {}
956
+ if "long_url" in updates:
957
+ validate_url(updates["long_url"])
958
+ safe_updates["long_url"] = updates["long_url"]
959
+ if "note" in updates:
960
+ if updates["note"] is not None and len(updates["note"]) > 2000:
961
+ raise ValidationError("note must not exceed 2000 characters")
962
+ safe_updates["note"] = updates["note"]
963
+ if "tags" in updates:
964
+ tags = updates["tags"]
965
+ if tags is not None:
966
+ if len(tags) > 20:
967
+ raise ValidationError("At most 20 tags are allowed")
968
+ for t in tags:
969
+ if not t or len(t) > 50:
970
+ raise ValidationError("Each tag must be 1-50 characters")
971
+ if "," in t:
972
+ raise ValidationError("Tags must not contain commas")
973
+ safe_updates["tags"] = ",".join(tags)
974
+ else:
975
+ safe_updates["tags"] = None
976
+ if "max_clicks" in updates:
977
+ if updates["max_clicks"] is not None and updates["max_clicks"] < 1:
978
+ raise ValidationError("max_clicks must be a positive integer")
979
+ safe_updates["max_clicks"] = updates["max_clicks"]
980
+ if "expires_at" in updates:
981
+ safe_updates["expires_at"] = updates["expires_at"].isoformat() if updates["expires_at"] else None
982
+ if "password" in updates:
983
+ safe_updates["password_hash"] = _hash_password(updates["password"]) if updates["password"] else None
984
+ if "webhook_url" in updates:
985
+ if updates["webhook_url"]:
986
+ validate_url(updates["webhook_url"])
987
+ safe_updates["webhook_url"] = updates["webhook_url"]
988
+ if "fallback_url" in updates:
989
+ if updates["fallback_url"]:
990
+ validate_url(updates["fallback_url"])
991
+ safe_updates["fallback_url"] = updates["fallback_url"]
992
+ if "campaign_id" in updates:
993
+ safe_updates["campaign_id"] = updates["campaign_id"]
994
+ if "custom_domain" in updates:
995
+ safe_updates["custom_domain"] = updates["custom_domain"]
996
+ if "geo_targeting" in updates:
997
+ safe_updates["geo_targeting"] = json.dumps(updates["geo_targeting"]) if updates["geo_targeting"] else None
998
+
999
+ if safe_updates:
1000
+ self.storage.update_link(short_code, **safe_updates)
1001
+ detail = "; ".join(f"{k}={v}" for k, v in safe_updates.items())
1002
+ self.storage.audit(owner_id, "UPDATE_LINK", short_code, detail)
1003
+ logger.info("Owner %s updated link '%s'", owner_id, short_code)
1004
+
1005
+ return self._row_to_link(self.storage.get_link(short_code))
1006
+
1007
+ def deactivate_link(self, short_code: str, owner_id: str) -> None:
1008
+ row = self.storage.get_link(short_code)
1009
+ if row is None:
1010
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1011
+ if row["owner_id"] != owner_id:
1012
+ raise AuthorizationError("You do not own this link")
1013
+ self.storage.deactivate_link(short_code)
1014
+ self.storage.audit(owner_id, "DEACTIVATE_LINK", short_code)
1015
+ logger.info("Owner %s deactivated link '%s'", owner_id, short_code)
1016
+
1017
+ def get_link_stats(self, short_code: str, owner_id: str) -> Dict[str, Any]:
1018
+ row = self.storage.get_link(short_code)
1019
+ if row is None:
1020
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1021
+ if row["owner_id"] != owner_id:
1022
+ raise AuthorizationError("You do not own this link")
1023
+ stats = self.storage.get_click_analytics(short_code)
1024
+ return {
1025
+ "short_code": row["short_code"],
1026
+ "long_url": row["long_url"],
1027
+ "click_count": row["click_count"],
1028
+ "created_at": row["created_at"],
1029
+ "last_accessed_at": row["last_accessed_at"],
1030
+ "is_active": bool(row["is_active"]),
1031
+ "expires_at": row["expires_at"],
1032
+ "max_clicks": row["max_clicks"],
1033
+ "has_password": bool(row["password_hash"]),
1034
+ "utm": {
1035
+ "source": row["utm_source"],
1036
+ "medium": row["utm_medium"],
1037
+ "campaign": row["utm_campaign"],
1038
+ },
1039
+ "analytics": stats,
1040
+ "campaign_id": row["campaign_id"],
1041
+ "custom_domain": row["custom_domain"],
1042
+ "fallback_url": row["fallback_url"],
1043
+ "webhook_url": row["webhook_url"],
1044
+ }
1045
+
1046
+ def get_click_details(self, short_code: str, owner_id: str, limit: int = 100) -> List[Dict[str, Any]]:
1047
+ row = self.storage.get_link(short_code)
1048
+ if row is None:
1049
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1050
+ if row["owner_id"] != owner_id:
1051
+ raise AuthorizationError("You do not own this link")
1052
+ import sqlite3 as _sqlite3
1053
+ rows = self.storage._conn.execute(
1054
+ "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
1055
+ "FROM url_shortener_clicks WHERE short_code = ? ORDER BY clicked_at DESC LIMIT ?",
1056
+ (short_code, limit),
1057
+ ).fetchall()
1058
+ return [dict(r) for r in rows]
1059
+
1060
+ def get_qr_code(self, short_code: str) -> Dict[str, str]:
1061
+ row = self.storage.get_link(short_code)
1062
+ if row is None:
1063
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1064
+ short_url = f"{SRV_BASE_URL}/url-shortener/{short_code}"
1065
+ return {
1066
+ "short_code": short_code,
1067
+ "short_url": short_url,
1068
+ "qr_image_url": _build_qr_data_url(short_code),
1069
+ }
1070
+
1071
+ def export_links(self, owner_id: str) -> str:
1072
+ return self.storage.export_links_csv(owner_id)
1073
+
1074
+ def export_clicks(self, short_code: str, owner_id: str) -> str:
1075
+ row = self.storage.get_link(short_code)
1076
+ if row is None:
1077
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1078
+ if row["owner_id"] != owner_id:
1079
+ raise AuthorizationError("You do not own this link")
1080
+ return self.storage.export_clicks_csv(short_code)
1081
+
1082
+ def owner_summary(self, owner_id: str) -> Dict[str, Any]:
1083
+ return self.storage.owner_summary(owner_id)
1084
+
1085
+ def create_campaign(self, owner_id: str, name: str, description: Optional[str] = None) -> Dict[str, Any]:
1086
+ plan_limits = self._get_owner_plan(owner_id)
1087
+ if not plan_limits.get("campaigns", False):
1088
+ raise PlanLimitExceededError("Your plan does not support campaigns. Upgrade to Pro or Enterprise.")
1089
+ campaign_id = secrets.token_hex(8)
1090
+ self.storage.create_campaign(campaign_id, owner_id, name, description)
1091
+ self.storage.audit(owner_id, "CREATE_CAMPAIGN", None, f"name={name}")
1092
+ logger.info("Owner %s created campaign '%s'", owner_id, campaign_id)
1093
+ return {"campaign_id": campaign_id, "name": name, "description": description}
1094
+
1095
+ def list_campaigns(self, owner_id: str) -> List[Dict[str, Any]]:
1096
+ rows = self.storage.list_campaigns_for_owner(owner_id)
1097
+ result = []
1098
+ for r in rows:
1099
+ link_count = self.storage.get_campaign_link_count(r["campaign_id"])
1100
+ total_clicks = self.storage.get_campaign_total_clicks(r["campaign_id"])
1101
+ result.append({
1102
+ "campaign_id": r["campaign_id"],
1103
+ "name": r["name"],
1104
+ "description": r["description"],
1105
+ "created_at": r["created_at"],
1106
+ "is_active": bool(r["is_active"]),
1107
+ "link_count": link_count,
1108
+ "total_clicks": total_clicks,
1109
+ })
1110
+ return result
1111
+
1112
+ def get_campaign(self, campaign_id: str, owner_id: str) -> Dict[str, Any]:
1113
+ row = self.storage.get_campaign(campaign_id)
1114
+ if row is None:
1115
+ raise LinkNotFoundError(f"Campaign '{campaign_id}' not found")
1116
+ if row["owner_id"] != owner_id:
1117
+ raise AuthorizationError("You do not own this campaign")
1118
+ link_count = self.storage.get_campaign_link_count(campaign_id)
1119
+ total_clicks = self.storage.get_campaign_total_clicks(campaign_id)
1120
+ analytics = self.storage.get_campaign_analytics(campaign_id)
1121
+ return {
1122
+ "campaign_id": row["campaign_id"],
1123
+ "name": row["name"],
1124
+ "description": row["description"],
1125
+ "created_at": row["created_at"],
1126
+ "is_active": bool(row["is_active"]),
1127
+ "link_count": link_count,
1128
+ "total_clicks": total_clicks,
1129
+ "analytics": analytics,
1130
+ }
1131
+
1132
+ def deactivate_campaign(self, campaign_id: str, owner_id: str) -> None:
1133
+ row = self.storage.get_campaign(campaign_id)
1134
+ if row is None:
1135
+ raise LinkNotFoundError(f"Campaign '{campaign_id}' not found")
1136
+ if row["owner_id"] != owner_id:
1137
+ raise AuthorizationError("You do not own this campaign")
1138
+ self.storage.deactivate_campaign(campaign_id)
1139
+ self.storage.audit(owner_id, "DEACTIVATE_CAMPAIGN", None, campaign_id)
1140
+ logger.info("Owner %s deactivated campaign '%s'", owner_id, campaign_id)
1141
+
1142
+ def list_campaign_links(self, campaign_id: str, owner_id: str) -> List[ShortLink]:
1143
+ row = self.storage.get_campaign(campaign_id)
1144
+ if row is None:
1145
+ raise LinkNotFoundError(f"Campaign '{campaign_id}' not found")
1146
+ if row["owner_id"] != owner_id:
1147
+ raise AuthorizationError("You do not own this campaign")
1148
+ return [self._row_to_link(r) for r in self.storage.list_links_by_campaign(campaign_id)]
1149
+
1150
+ def check_link_health(self, short_code: str, owner_id: str) -> Dict[str, Any]:
1151
+ row = self.storage.get_link(short_code)
1152
+ if row is None:
1153
+ raise LinkNotFoundError(f"No link found for code '{short_code}'")
1154
+ if row["owner_id"] != owner_id:
1155
+ raise AuthorizationError("You do not own this link")
1156
+ url = row["long_url"]
1157
+ result = {"short_code": short_code, "long_url": url, "reachable": False, "status_code": None, "error": None}
1158
+ try:
1159
+ import httpx
1160
+ resp = httpx.get(url, timeout=10.0, follow_redirects=True)
1161
+ result["reachable"] = resp.is_success
1162
+ result["status_code"] = resp.status_code
1163
+ except ImportError:
1164
+ import urllib.request
1165
+ try:
1166
+ resp = urllib.request.urlopen(url, timeout=10)
1167
+ result["reachable"] = True
1168
+ result["status_code"] = resp.getcode()
1169
+ except Exception as exc:
1170
+ result["error"] = str(exc)
1171
+ except Exception as exc:
1172
+ result["error"] = str(exc)
1173
+ return result
1174
+
1175
+ def _row_to_link(self, row: sqlite3.Row) -> ShortLink:
1176
+ gt_raw = row["geo_targeting"]
1177
+ geo = json.loads(gt_raw) if isinstance(gt_raw, str) and gt_raw else None
1178
+ return ShortLink(
1179
+ short_code=row["short_code"],
1180
+ long_url=row["long_url"],
1181
+ owner_id=row["owner_id"],
1182
+ created_at=_parse_iso(row["created_at"]),
1183
+ expires_at=_parse_iso(row["expires_at"]),
1184
+ max_clicks=row["max_clicks"],
1185
+ click_count=row["click_count"],
1186
+ is_active=bool(row["is_active"]),
1187
+ tags=row["tags"].split(",") if row["tags"] else [],
1188
+ note=row["note"],
1189
+ password_hash=row["password_hash"],
1190
+ utm_source=row["utm_source"],
1191
+ utm_medium=row["utm_medium"],
1192
+ utm_campaign=row["utm_campaign"],
1193
+ campaign_id=row["campaign_id"],
1194
+ custom_domain=row["custom_domain"],
1195
+ fallback_url=row["fallback_url"],
1196
+ webhook_url=row["webhook_url"],
1197
+ geo_targeting=geo,
1198
+ )