marqetive-lib 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
marqetive/core/client.py CHANGED
@@ -5,6 +5,11 @@ from typing import Any
5
5
  import httpx
6
6
  from pydantic import BaseModel
7
7
 
8
+ # Default connection pool limits for optimal performance
9
+ DEFAULT_MAX_CONNECTIONS = 100
10
+ DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
11
+ DEFAULT_KEEPALIVE_EXPIRY = 30.0
12
+
8
13
 
9
14
  class APIResponse(BaseModel):
10
15
  """Response model for API calls."""
@@ -24,6 +29,8 @@ class APIClient:
24
29
  base_url: The base URL for API requests
25
30
  timeout: Request timeout in seconds (default: 30)
26
31
  headers: Optional default headers for all requests
32
+ max_connections: Maximum total connections in pool (default: 100)
33
+ max_keepalive_connections: Maximum persistent connections (default: 20)
27
34
 
28
35
  Example:
29
36
  >>> client = APIClient(base_url="https://api.example.com")
@@ -36,12 +43,19 @@ class APIClient:
36
43
  base_url: str,
37
44
  timeout: float = 30.0,
38
45
  headers: dict[str, str] | None = None,
46
+ max_connections: int = DEFAULT_MAX_CONNECTIONS,
47
+ max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
39
48
  ) -> None:
40
49
  """Initialize the API client."""
41
50
  self.base_url = base_url.rstrip("/")
42
51
  self.timeout = timeout
43
52
  self.default_headers = headers or {}
44
53
  self._client: httpx.AsyncClient | None = None
54
+ self._limits = httpx.Limits(
55
+ max_connections=max_connections,
56
+ max_keepalive_connections=max_keepalive_connections,
57
+ keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY,
58
+ )
45
59
 
46
60
  async def __aenter__(self) -> "APIClient":
47
61
  """Async context manager entry."""
@@ -49,6 +63,7 @@ class APIClient:
49
63
  base_url=self.base_url,
50
64
  timeout=self.timeout,
51
65
  headers=self.default_headers,
66
+ limits=self._limits,
52
67
  )
53
68
  return self
54
69
 
@@ -35,6 +35,7 @@ from marqetive.platforms.instagram.media import (
35
35
  InstagramMediaManager,
36
36
  MediaItem,
37
37
  )
38
+ from marqetive.utils.media import validate_media_url
38
39
 
39
40
 
40
41
  class InstagramClient(SocialMediaPlatform):
@@ -245,7 +246,11 @@ class InstagramClient(SocialMediaPlatform):
245
246
  }
246
247
 
247
248
  if request.media_urls:
248
- container_params["image_url"] = request.media_urls[0]
249
+ # Validate URL to prevent SSRF attacks
250
+ validated_url = validate_media_url(
251
+ request.media_urls[0], platform=self.platform_name
252
+ )
253
+ container_params["image_url"] = validated_url
249
254
 
250
255
  if request.content:
251
256
  container_params["caption"] = request.content
@@ -533,14 +538,17 @@ class InstagramClient(SocialMediaPlatform):
533
538
  if not self.api_client:
534
539
  raise RuntimeError("Client must be used as async context manager")
535
540
 
541
+ # Validate URL to prevent SSRF attacks
542
+ validated_url = validate_media_url(media_url, platform=self.platform_name)
543
+
536
544
  params: dict[str, Any] = {
537
545
  "access_token": self.credentials.access_token,
538
546
  }
539
547
 
540
548
  if media_type.lower() == "image":
541
- params["image_url"] = media_url
549
+ params["image_url"] = validated_url
542
550
  elif media_type.lower() == "video":
543
- params["video_url"] = media_url
551
+ params["video_url"] = validated_url
544
552
  params["media_type"] = "VIDEO"
545
553
  else:
546
554
  raise ValidationError(
@@ -612,13 +620,15 @@ class InstagramClient(SocialMediaPlatform):
612
620
  if not self._media_manager:
613
621
  raise RuntimeError("Client must be used as async context manager")
614
622
 
615
- # Convert to MediaItem objects
623
+ # Convert to MediaItem objects with URL validation
616
624
  media_items = []
617
625
  for idx, url in enumerate(media_urls):
626
+ # Validate each URL to prevent SSRF attacks
627
+ validated_url = validate_media_url(url, platform=self.platform_name)
618
628
  alt_text = None
619
629
  if alt_texts and idx < len(alt_texts):
620
630
  alt_text = alt_texts[idx]
621
- media_items.append(MediaItem(url=url, type="image", alt_text=alt_text))
631
+ media_items.append(MediaItem(url=validated_url, type="image", alt_text=alt_text))
622
632
 
623
633
  # Create containers
624
634
  container_ids = await self._media_manager.create_feed_containers(
@@ -667,11 +677,19 @@ class InstagramClient(SocialMediaPlatform):
667
677
  if not self._media_manager:
668
678
  raise RuntimeError("Client must be used as async context manager")
669
679
 
680
+ # Validate URLs to prevent SSRF attacks
681
+ validated_video_url = validate_media_url(video_url, platform=self.platform_name)
682
+ validated_cover_url = None
683
+ if cover_url:
684
+ validated_cover_url = validate_media_url(
685
+ cover_url, platform=self.platform_name
686
+ )
687
+
670
688
  # Create reel container
671
689
  container_id = await self._media_manager.create_reel_container(
672
- video_url,
690
+ validated_video_url,
673
691
  caption=caption,
674
- cover_url=cover_url,
692
+ cover_url=validated_cover_url,
675
693
  share_to_feed=share_to_feed,
676
694
  wait_for_processing=True,
677
695
  )
@@ -710,9 +728,12 @@ class InstagramClient(SocialMediaPlatform):
710
728
  if not self._media_manager:
711
729
  raise RuntimeError("Client must be used as async context manager")
712
730
 
731
+ # Validate URL to prevent SSRF attacks
732
+ validated_url = validate_media_url(media_url, platform=self.platform_name)
733
+
713
734
  # Create story container
714
735
  container_id = await self._media_manager.create_story_container(
715
- media_url,
736
+ validated_url,
716
737
  media_type,
717
738
  wait_for_processing=(media_type == "video"),
718
739
  )
@@ -33,9 +33,9 @@ from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_er
33
33
  from marqetive.platforms.tiktok.media import (
34
34
  CreatorInfo,
35
35
  MediaUploadResult,
36
- PrivacyLevel,
37
36
  TikTokMediaManager,
38
37
  )
38
+ from marqetive.platforms.tiktok.models import PrivacyLevel
39
39
 
40
40
  # TikTok API base URL
41
41
  TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
@@ -107,6 +107,7 @@ class TikTokClient(SocialMediaPlatform):
107
107
  open_id=self.credentials.additional_data["open_id"],
108
108
  timeout=self.timeout,
109
109
  )
110
+ await self._media_manager.__aenter__()
110
111
 
111
112
  async def _cleanup_managers(self) -> None:
112
113
  """Cleanup media manager."""
@@ -213,7 +214,7 @@ class TikTokClient(SocialMediaPlatform):
213
214
  await self.query_creator_info()
214
215
 
215
216
  # 2. Determine privacy level
216
- privacy_level = PrivacyLevel.SELF_ONLY # Default for unaudited apps
217
+ privacy_level = PrivacyLevel.PRIVATE # Default for unaudited apps
217
218
  requested_privacy = request.additional_data.get("privacy_level")
218
219
  if requested_privacy and self._creator_info:
219
220
  # Check if requested privacy is available
@@ -293,7 +294,7 @@ class TikTokClient(SocialMediaPlatform):
293
294
 
294
295
  async def update_post(
295
296
  self,
296
- post_id: str,
297
+ post_id: str, # noqa: ARG002
297
298
  request: PostUpdateRequest, # noqa: ARG002
298
299
  ) -> Post:
299
300
  """Update a TikTok video.
@@ -340,7 +341,7 @@ class TikTokClient(SocialMediaPlatform):
340
341
 
341
342
  async def create_comment(
342
343
  self,
343
- post_id: str,
344
+ post_id: str, # noqa: ARG002
344
345
  content: str, # noqa: ARG002
345
346
  ) -> Comment:
346
347
  """Create a comment on a TikTok video.
@@ -11,6 +11,7 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
11
11
 
12
12
  import asyncio
13
13
  import inspect
14
+ import json
14
15
  import logging
15
16
  import math
16
17
  import os
@@ -19,14 +20,17 @@ from dataclasses import dataclass
19
20
  from enum import Enum
20
21
  from typing import Any, Literal
21
22
 
23
+ import aiofiles
22
24
  import httpx
23
25
 
24
26
  from marqetive.core.exceptions import (
25
27
  InvalidFileTypeError,
26
28
  MediaUploadError,
29
+ PlatformError,
27
30
  )
28
31
  from marqetive.core.models import ProgressEvent, ProgressStatus
29
32
  from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
33
+ from marqetive.platforms.tiktok.models import PrivacyLevel
30
34
  from marqetive.utils.file_handlers import download_file
31
35
  from marqetive.utils.media import detect_mime_type, format_file_size
32
36
 
@@ -69,14 +73,6 @@ class PublishStatus(str, Enum):
69
73
  FAILED = "FAILED"
70
74
 
71
75
 
72
- class PrivacyLevel(str, Enum):
73
- """Privacy level options for TikTok posts."""
74
-
75
- PUBLIC_TO_EVERYONE = "PUBLIC_TO_EVERYONE"
76
- MUTUAL_FOLLOW_FRIENDS = "MUTUAL_FOLLOW_FRIENDS"
77
- SELF_ONLY = "SELF_ONLY" # Private - only for unaudited apps or private posts
78
-
79
-
80
76
  @dataclass
81
77
  class UploadProgress:
82
78
  """Progress information for a media upload.
@@ -196,6 +192,27 @@ class TikTokMediaManager:
196
192
  await self._client.aclose()
197
193
  await self._upload_client.aclose()
198
194
 
195
+ def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
196
+ """Parse JSON from HTTP response with proper error handling.
197
+
198
+ Args:
199
+ response: HTTP response object.
200
+
201
+ Returns:
202
+ Parsed JSON data as dictionary.
203
+
204
+ Raises:
205
+ PlatformError: If response is not valid JSON.
206
+ """
207
+ try:
208
+ return response.json()
209
+ except json.JSONDecodeError as e:
210
+ raise PlatformError(
211
+ f"Invalid JSON response from TikTok API: {e}",
212
+ platform="tiktok",
213
+ status_code=response.status_code,
214
+ ) from e
215
+
199
216
  async def _emit_progress(
200
217
  self,
201
218
  status: ProgressStatus,
@@ -249,7 +266,7 @@ class TikTokMediaManager:
249
266
  url = f"{TIKTOK_API_BASE}/post/publish/creator_info/query/"
250
267
 
251
268
  response = await self._client.post(url)
252
- data = response.json()
269
+ data = self._parse_json_response(response)
253
270
 
254
271
  self._check_response_error(response.status_code, data)
255
272
 
@@ -272,7 +289,7 @@ class TikTokMediaManager:
272
289
  file_path: str,
273
290
  *,
274
291
  title: str = "",
275
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
292
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
276
293
  disable_duet: bool = False,
277
294
  disable_comment: bool = False,
278
295
  disable_stitch: bool = False,
@@ -321,7 +338,7 @@ class TikTokMediaManager:
321
338
  }
322
339
 
323
340
  response = await self._client.post(url, json=payload)
324
- data = response.json()
341
+ data = self._parse_json_response(response)
325
342
 
326
343
  self._check_response_error(response.status_code, data)
327
344
 
@@ -343,7 +360,7 @@ class TikTokMediaManager:
343
360
  video_url: str,
344
361
  *,
345
362
  title: str = "",
346
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
363
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
347
364
  disable_duet: bool = False,
348
365
  disable_comment: bool = False,
349
366
  disable_stitch: bool = False,
@@ -386,7 +403,7 @@ class TikTokMediaManager:
386
403
  }
387
404
 
388
405
  response = await self._client.post(url, json=payload)
389
- data = response.json()
406
+ data = self._parse_json_response(response)
390
407
 
391
408
  self._check_response_error(response.status_code, data)
392
409
 
@@ -439,10 +456,10 @@ class TikTokMediaManager:
439
456
 
440
457
  bytes_uploaded = 0
441
458
 
442
- with open(file_path, "rb") as f:
459
+ async with aiofiles.open(file_path, "rb") as f:
443
460
  chunk_index = 0
444
461
  while True:
445
- chunk_data = f.read(chunk_size)
462
+ chunk_data = await f.read(chunk_size)
446
463
  if not chunk_data:
447
464
  break
448
465
 
@@ -507,7 +524,7 @@ class TikTokMediaManager:
507
524
  url = f"{TIKTOK_API_BASE}/post/publish/status/fetch/"
508
525
 
509
526
  response = await self._client.post(url, json={"publish_id": publish_id})
510
- data = response.json()
527
+ data = self._parse_json_response(response)
511
528
 
512
529
  self._check_response_error(response.status_code, data)
513
530
 
@@ -614,7 +631,7 @@ class TikTokMediaManager:
614
631
  file_path: str,
615
632
  *,
616
633
  title: str = "",
617
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
634
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
618
635
  chunk_size: int = DEFAULT_CHUNK_SIZE,
619
636
  wait_for_publish: bool = True,
620
637
  ) -> MediaUploadResult:
@@ -642,43 +659,56 @@ class TikTokMediaManager:
642
659
  InvalidFileTypeError: If the file is not a supported video format.
643
660
  MediaUploadError: If upload or processing fails.
644
661
  """
645
- # Handle URL downloads
646
- if file_path.startswith(("http://", "https://")):
647
- logger.info(f"Downloading media from URL: {file_path}")
648
- file_path = await download_file(file_path)
649
-
650
- if not os.path.exists(file_path):
651
- raise FileNotFoundError(f"Media file not found: {file_path}")
652
-
653
- # Validate file
654
- mime_type = detect_mime_type(file_path)
655
- file_size = os.path.getsize(file_path)
656
- self._validate_media(mime_type, file_size)
657
-
658
- # Initialize upload
659
- init_result = await self.init_video_upload(
660
- file_path,
661
- title=title,
662
- privacy_level=privacy_level,
663
- chunk_size=chunk_size,
664
- )
662
+ # Track if we downloaded a temp file that needs cleanup
663
+ temp_file_path: str | None = None
664
+
665
+ try:
666
+ # Handle URL downloads
667
+ if file_path.startswith(("http://", "https://")):
668
+ logger.info(f"Downloading media from URL: {file_path}")
669
+ file_path = await download_file(file_path)
670
+ temp_file_path = file_path # Mark for cleanup
671
+
672
+ if not os.path.exists(file_path):
673
+ raise FileNotFoundError(f"Media file not found: {file_path}")
674
+
675
+ # Validate file
676
+ mime_type = detect_mime_type(file_path)
677
+ file_size = os.path.getsize(file_path)
678
+ self._validate_media(mime_type, file_size)
679
+
680
+ # Initialize upload
681
+ init_result = await self.init_video_upload(
682
+ file_path,
683
+ title=title,
684
+ privacy_level=privacy_level,
685
+ chunk_size=chunk_size,
686
+ )
665
687
 
666
- # Upload chunks
667
- await self.upload_video_chunks(
668
- init_result.upload_url,
669
- file_path,
670
- init_result.publish_id,
671
- chunk_size=chunk_size,
672
- )
688
+ # Upload chunks
689
+ await self.upload_video_chunks(
690
+ init_result.upload_url,
691
+ file_path,
692
+ init_result.publish_id,
693
+ chunk_size=chunk_size,
694
+ )
673
695
 
674
- # Wait for publish
675
- if wait_for_publish:
676
- return await self.wait_for_publish(init_result.publish_id, file_path)
696
+ # Wait for publish
697
+ if wait_for_publish:
698
+ return await self.wait_for_publish(init_result.publish_id, file_path)
677
699
 
678
- return MediaUploadResult(
679
- publish_id=init_result.publish_id,
680
- status=PublishStatus.PROCESSING_UPLOAD,
681
- )
700
+ return MediaUploadResult(
701
+ publish_id=init_result.publish_id,
702
+ status=PublishStatus.PROCESSING_UPLOAD,
703
+ )
704
+ finally:
705
+ # Clean up downloaded temp file
706
+ if temp_file_path and os.path.exists(temp_file_path):
707
+ try:
708
+ os.remove(temp_file_path)
709
+ logger.debug(f"Cleaned up temp file: {temp_file_path}")
710
+ except OSError as e:
711
+ logger.warning(f"Failed to clean up temp file {temp_file_path}: {e}")
682
712
 
683
713
  def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
684
714
  """Normalize chunk size to TikTok's requirements.
@@ -24,7 +24,10 @@ class PrivacyLevel(StrEnum):
24
24
 
25
25
 
26
26
  class TikTokPostRequest(BaseModel):
27
- """TikTok-specific post creation request.
27
+ """TikTok-specific post creation request with full API options.
28
+
29
+ This model provides a type-safe way to configure TikTok-specific post settings.
30
+ It can be converted to the universal PostCreateRequest for use with TikTokClient.
28
31
 
29
32
  TikTok only supports video posts. Videos must be between 3 seconds
30
33
  and 10 minutes, and under 4GB in size.
@@ -43,30 +46,24 @@ class TikTokPostRequest(BaseModel):
43
46
  schedule_time: Unix timestamp to schedule post (10 mins to 10 days ahead)
44
47
 
45
48
  Example:
46
- >>> # Public video post
47
- >>> request = TikTokPostRequest(
49
+ >>> from marqetive.core.models import PostCreateRequest
50
+ >>>
51
+ >>> # Create TikTok-specific request
52
+ >>> tiktok_request = TikTokPostRequest(
48
53
  ... title="Check out this dance!",
49
54
  ... video_url="https://example.com/dance.mp4",
50
- ... privacy_level=PrivacyLevel.PUBLIC
51
- ... )
52
-
53
- >>> # Private video with features disabled
54
- >>> request = TikTokPostRequest(
55
- ... title="Personal video",
56
- ... video_url="https://example.com/video.mp4",
57
- ... privacy_level=PrivacyLevel.PRIVATE,
58
- ... disable_comment=True,
55
+ ... privacy_level=PrivacyLevel.PUBLIC,
59
56
  ... disable_duet=True,
60
- ... disable_stitch=True
61
57
  ... )
62
-
63
- >>> # Branded content
64
- >>> request = TikTokPostRequest(
65
- ... title="Sponsored review",
66
- ... video_url="https://example.com/review.mp4",
67
- ... privacy_level=PrivacyLevel.PUBLIC,
68
- ... brand_content_toggle=True
58
+ >>>
59
+ >>> # Convert to universal PostCreateRequest for client
60
+ >>> request = PostCreateRequest(
61
+ ... content=tiktok_request.title,
62
+ ... media_urls=[tiktok_request.video_url] if tiktok_request.video_url else [],
63
+ ... additional_data=tiktok_request.model_dump(exclude_none=True),
69
64
  ... )
65
+ >>> async with TikTokClient(credentials) as client:
66
+ ... post = await client.create_post(request)
70
67
  """
71
68
 
72
69
  title: str | None = None
@@ -17,6 +17,7 @@ from dataclasses import dataclass
17
17
  from enum import Enum
18
18
  from typing import Any, Literal
19
19
 
20
+ import aiofiles
20
21
  import httpx
21
22
 
22
23
  from marqetive.core.exceptions import (
@@ -337,9 +338,9 @@ class TwitterMediaManager:
337
338
 
338
339
  @retry_async(config=STANDARD_BACKOFF)
339
340
  async def _do_upload() -> MediaUploadResult:
340
- # Read file
341
- with open(file_path, "rb") as f:
342
- file_data = f.read()
341
+ # Read file asynchronously
342
+ async with aiofiles.open(file_path, "rb") as f:
343
+ file_data = await f.read()
343
344
 
344
345
  # Prepare form data
345
346
  files = {"media": (os.path.basename(file_path), file_data)}
@@ -465,9 +466,9 @@ class TwitterMediaManager:
465
466
  bytes_uploaded = 0
466
467
  segment_index = 0
467
468
 
468
- with open(file_path, "rb") as f:
469
+ async with aiofiles.open(file_path, "rb") as f:
469
470
  while True:
470
- chunk_data = f.read(chunk_size)
471
+ chunk_data = await f.read(chunk_size)
471
472
  if not chunk_data:
472
473
  break
473
474
 
marqetive/utils/media.py CHANGED
@@ -5,14 +5,19 @@ This module provides utilities for working with media files including:
5
5
  - File validation (size, type, format)
6
6
  - File chunking for large uploads
7
7
  - File hashing for integrity verification
8
+ - URL validation for media URLs
8
9
  """
9
10
 
10
11
  import hashlib
12
+ import ipaddress
11
13
  import mimetypes
12
14
  import os
13
15
  from collections.abc import AsyncGenerator
14
16
  from pathlib import Path
15
17
  from typing import Literal
18
+ from urllib.parse import urlparse
19
+
20
+ from marqetive.core.exceptions import ValidationError
16
21
 
17
22
  # Initialize mimetypes database
18
23
  mimetypes.init()
@@ -397,3 +402,84 @@ def get_chunk_count(file_path: str, chunk_size: int) -> int:
397
402
 
398
403
  file_size = os.path.getsize(file_path)
399
404
  return (file_size + chunk_size - 1) // chunk_size # Ceiling division
405
+
406
+
407
+ def validate_media_url(
408
+ url: str,
409
+ *,
410
+ allowed_schemes: list[str] | None = None,
411
+ block_private_ips: bool = True,
412
+ platform: str = "unknown",
413
+ ) -> str:
414
+ """Validate a media URL for security.
415
+
416
+ Validates that the URL uses an allowed scheme (default: http/https) and
417
+ optionally blocks private/internal IP addresses to prevent SSRF attacks.
418
+
419
+ Args:
420
+ url: The URL to validate.
421
+ allowed_schemes: List of allowed URL schemes (default: ['http', 'https']).
422
+ block_private_ips: If True, block private/loopback IP addresses.
423
+ platform: Platform name for error messages.
424
+
425
+ Returns:
426
+ The validated URL (unchanged if valid).
427
+
428
+ Raises:
429
+ ValidationError: If the URL is invalid or uses a disallowed scheme/IP.
430
+
431
+ Example:
432
+ >>> url = validate_media_url("https://example.com/image.jpg")
433
+ >>> url = validate_media_url("https://cdn.example.com/video.mp4", platform="instagram")
434
+ """
435
+ if allowed_schemes is None:
436
+ allowed_schemes = ["http", "https"]
437
+
438
+ try:
439
+ parsed = urlparse(url)
440
+ except Exception as e:
441
+ raise ValidationError(
442
+ f"Invalid URL format: {e}",
443
+ platform=platform,
444
+ field="media_url",
445
+ ) from e
446
+
447
+ # Validate scheme
448
+ if not parsed.scheme:
449
+ raise ValidationError(
450
+ "URL must include a scheme (e.g., https://)",
451
+ platform=platform,
452
+ field="media_url",
453
+ )
454
+
455
+ if parsed.scheme.lower() not in allowed_schemes:
456
+ raise ValidationError(
457
+ f"URL scheme '{parsed.scheme}' not allowed. "
458
+ f"Allowed schemes: {', '.join(allowed_schemes)}",
459
+ platform=platform,
460
+ field="media_url",
461
+ )
462
+
463
+ # Validate hostname exists
464
+ if not parsed.hostname:
465
+ raise ValidationError(
466
+ "URL must include a hostname",
467
+ platform=platform,
468
+ field="media_url",
469
+ )
470
+
471
+ # Block private/internal IPs if requested
472
+ if block_private_ips:
473
+ try:
474
+ ip = ipaddress.ip_address(parsed.hostname)
475
+ if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
476
+ raise ValidationError(
477
+ "Private, loopback, and reserved IP addresses are not allowed",
478
+ platform=platform,
479
+ field="media_url",
480
+ )
481
+ except ValueError:
482
+ # Not an IP address, it's a hostname - that's fine
483
+ pass
484
+
485
+ return url
marqetive/utils/oauth.py CHANGED
@@ -5,6 +5,7 @@ different social media platforms.
5
5
  """
6
6
 
7
7
  import logging
8
+ import re
8
9
  from datetime import datetime, timedelta
9
10
  from typing import Any
10
11
 
@@ -15,6 +16,32 @@ from marqetive.core.models import AuthCredentials
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
19
+ # Patterns for sensitive data that should be redacted from logs
20
+ _SENSITIVE_PATTERNS = [
21
+ re.compile(r'"access_token"\s*:\s*"[^"]*"', re.IGNORECASE),
22
+ re.compile(r'"refresh_token"\s*:\s*"[^"]*"', re.IGNORECASE),
23
+ re.compile(r'"client_secret"\s*:\s*"[^"]*"', re.IGNORECASE),
24
+ re.compile(r'"api_key"\s*:\s*"[^"]*"', re.IGNORECASE),
25
+ re.compile(r'"token"\s*:\s*"[^"]*"', re.IGNORECASE),
26
+ re.compile(r"access_token=[^&\s]+", re.IGNORECASE),
27
+ re.compile(r"refresh_token=[^&\s]+", re.IGNORECASE),
28
+ ]
29
+
30
+
31
+ def _sanitize_response_text(text: str) -> str:
32
+ """Sanitize response text to remove sensitive credentials.
33
+
34
+ Args:
35
+ text: Raw response text that may contain credentials.
36
+
37
+ Returns:
38
+ Sanitized text with sensitive values redacted.
39
+ """
40
+ result = text
41
+ for pattern in _SENSITIVE_PATTERNS:
42
+ result = pattern.sub("[REDACTED]", result)
43
+ return result
44
+
18
45
 
19
46
  async def refresh_oauth2_token(
20
47
  refresh_token: str,
@@ -73,7 +100,7 @@ async def refresh_oauth2_token(
73
100
  except httpx.HTTPStatusError as e:
74
101
  logger.error(f"HTTP error refreshing token: {e.response.status_code}")
75
102
  raise PlatformAuthError(
76
- f"Failed to refresh token: {e.response.text}",
103
+ f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
77
104
  platform="oauth2",
78
105
  status_code=e.response.status_code,
79
106
  ) from e
@@ -252,7 +279,7 @@ async def refresh_instagram_token(
252
279
  except httpx.HTTPStatusError as e:
253
280
  logger.error(f"HTTP error refreshing Instagram token: {e.response.status_code}")
254
281
  raise PlatformAuthError(
255
- f"Failed to refresh Instagram token: {e.response.text}",
282
+ f"Failed to refresh Instagram token: {_sanitize_response_text(e.response.text)}",
256
283
  platform="instagram",
257
284
  status_code=e.response.status_code,
258
285
  ) from e
@@ -312,7 +339,7 @@ async def refresh_tiktok_token(
312
339
  except httpx.HTTPStatusError as e:
313
340
  logger.error(f"HTTP error refreshing tiktok token: {e.response.status_code}")
314
341
  raise PlatformAuthError(
315
- f"Failed to refresh token: {e.response.text}",
342
+ f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
316
343
  platform="tiktok",
317
344
  status_code=e.response.status_code,
318
345
  ) from e
@@ -387,7 +414,7 @@ async def fetch_tiktok_token(
387
414
  except httpx.HTTPStatusError as e:
388
415
  logger.error(f"HTTP error fetching tiktok token: {e.response.status_code}")
389
416
  raise PlatformAuthError(
390
- f"Failed to fetch token: {e.response.text}",
417
+ f"Failed to fetch token: {_sanitize_response_text(e.response.text)}",
391
418
  platform="tiktok",
392
419
  status_code=e.response.status_code,
393
420
  ) from e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -1,13 +1,13 @@
1
1
  marqetive/__init__.py,sha256=pW77CUnzOQ0X1pb-GTcRgrrvsSaJBdVhGZLnvCD_4q4,3032
2
2
  marqetive/core/__init__.py,sha256=0_0vzxJ619YIJkz1yzSvhnGDJRkrErs_QSg2q3Bloss,1172
3
3
  marqetive/core/base.py,sha256=J5iYXpa2C371zVLNMx3eFnC0fdLWqTrjbVcqJQdGyrU,16147
4
- marqetive/core/client.py,sha256=2_FoNpqaRglsWg10i5RTbyDg_kRQKhgWjYs6iDdFxLg,3210
4
+ marqetive/core/client.py,sha256=eCtvL100dkxYQHC_TJzHbs3dGjgsa_me9VTHo-CUN2M,3900
5
5
  marqetive/core/exceptions.py,sha256=Xyj0bzNiZm5VTErmzXgVW8T6IQnOpF92-HJiKPKjIio,7076
6
6
  marqetive/core/models.py,sha256=L2gA4FhW0feAXQFsz2ce1ttd0vScMRhatoTclhDGCU0,14727
7
7
  marqetive/factory.py,sha256=irZ5oN8a__kXZH70UN2uI7TzqTXu66d4QZ1FoxSoiK8,14092
8
8
  marqetive/platforms/__init__.py,sha256=RBxlQSGyELsulSnwf5uaE1ohxFc7jC61OO9CrKaZp48,1312
9
9
  marqetive/platforms/instagram/__init__.py,sha256=c1Gs0ozG6D7Z-Uz_UQ7S3joL0qUTT9eUZPWcePyESk8,229
10
- marqetive/platforms/instagram/client.py,sha256=C5T9v1Aina8F3_Dk3d_I_RiVt7VvxTwwqdYeizDr0iQ,25187
10
+ marqetive/platforms/instagram/client.py,sha256=vOx5HpgrxanBIFFC9VgmCNguH-njRGChnyp6Rr1r1Xc,26191
11
11
  marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3VW7cMqM8DmiXIhg,8978
12
12
  marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
13
13
  marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
@@ -17,23 +17,23 @@ marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx
17
17
  marqetive/platforms/linkedin/media.py,sha256=iWXUfqDYGsrTqeM6CGZ7a8xjpbdJ5qESolQL8Fv2PIg,20341
18
18
  marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
19
19
  marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
20
- marqetive/platforms/tiktok/client.py,sha256=i_nyhVywh4feFu5vv4LrMQOkoLsxfxgpH6aKa9JjdOc,17060
20
+ marqetive/platforms/tiktok/client.py,sha256=wCCCFQ4mGiZrrGYjRUCUngz6_eqf4G6BUxYxw8szpig,17178
21
21
  marqetive/platforms/tiktok/exceptions.py,sha256=vxwyAKujMGZJh0LetG1QsLF95QfUs_kR6ujsWSHGqL0,10124
22
- marqetive/platforms/tiktok/media.py,sha256=nDijJYq89dQfMFwq58P2PXOvZukxgVRzMrfKTOONn_c,25717
23
- marqetive/platforms/tiktok/models.py,sha256=acV-fe_3O6kKbZzqytCNCATAwmFpjt0Ac2uacbjt1u8,2822
22
+ marqetive/platforms/tiktok/media.py,sha256=bPQmyVL8egb4teXQDzxQvWLwg2EnBh4Ik6lz20ReFvg,27008
23
+ marqetive/platforms/tiktok/models.py,sha256=WWdjuFqhTIR8SnHkz-8UaNc5Mm2PrGomwQ3W7pJcQFg,2962
24
24
  marqetive/platforms/twitter/__init__.py,sha256=dvcgVT-v-JOtjSz-OUvxGrn_43OI6w_ep42Wx_nHTSM,217
25
25
  marqetive/platforms/twitter/client.py,sha256=08jV2hQVmGOpnG3C05u7bCqL7KapWn7bSsG0wbN_t5M,23270
26
26
  marqetive/platforms/twitter/exceptions.py,sha256=eZ-dJKOXH_-bAMg29zWKbEqMFud29piEJ5IWfC9wFts,8926
27
- marqetive/platforms/twitter/media.py,sha256=_7ka0ENlKr6EMxQQvPS_A38_a8MpjDE2zIrXe20QkuI,27079
27
+ marqetive/platforms/twitter/media.py,sha256=9j7JQpdlOhkMfQkDH0dLpp6HmlYkeB6SvNosRx5Oab8,27152
28
28
  marqetive/platforms/twitter/models.py,sha256=yPQlx40SlNmz7YGasXUqdx7rEDEgrQ64aYovlPKo6oc,2126
29
29
  marqetive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  marqetive/utils/__init__.py,sha256=bSrNajbxYBSKQayrPviLz8JeGjplnyK8y_NGDtgb7yQ,977
31
31
  marqetive/utils/file_handlers.py,sha256=4TP5kmWofNTSZmlS683CM1UYP83WvRd_NubMbqtXv-g,12568
32
32
  marqetive/utils/helpers.py,sha256=8-ljhL47SremKcQO2GF8DIHOPODEv1rSioVNuSPCbec,2634
33
- marqetive/utils/media.py,sha256=Rvxw9XKU65n-z4G1bEihG3wXZBmjSDZUqClfjGFrg6k,12013
34
- marqetive/utils/oauth.py,sha256=LQLXpThZUe0XbSpO3dJ5oW3sPRJuKjSk3_f5_3baUzA,12095
33
+ marqetive/utils/media.py,sha256=O1rISYdaP3CuuPxso7kqvxWXNfe2jjioNkaBc4cpwkY,14668
34
+ marqetive/utils/oauth.py,sha256=1SkYCE6dcyPvcDqbjRFSSBcKTwLJy8u3jAANPdftVmo,13108
35
35
  marqetive/utils/retry.py,sha256=lAniJLMNWp9XsHrvU0XBNifpNEjfde4MGfd5hlFTPfA,7636
36
36
  marqetive/utils/token_validator.py,sha256=dNvDeHs2Du5UyMMH2ZOW6ydR7OwOEKA4c9e-rG0f9-0,6698
37
- marqetive_lib-0.1.7.dist-info/METADATA,sha256=xsPKt4AbFy44aZtNeft0YeaSWNKrS-xWxsavavh2xtM,7875
38
- marqetive_lib-0.1.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
39
- marqetive_lib-0.1.7.dist-info/RECORD,,
37
+ marqetive_lib-0.1.8.dist-info/METADATA,sha256=q99TsfInwfKVr_zoFnkvM3Wvb5cGZ7Nj6qQikle5EG4,7875
38
+ marqetive_lib-0.1.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
39
+ marqetive_lib-0.1.8.dist-info/RECORD,,