winebox 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -4,42 +4,171 @@ import uuid
4
4
  from pathlib import Path
5
5
 
6
6
  import aiofiles
7
- from fastapi import UploadFile
7
+ from fastapi import HTTPException, UploadFile, status
8
8
 
9
9
  from winebox.config import settings
10
10
 
11
+ # Allowed MIME types for image uploads
12
+ ALLOWED_MIME_TYPES = {
13
+ "image/jpeg",
14
+ "image/png",
15
+ "image/gif",
16
+ "image/webp",
17
+ }
18
+
19
+ # File extension to MIME type mapping
20
+ EXTENSION_MIME_MAP = {
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ ".png": "image/png",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ }
27
+
28
+ # Magic byte signatures for image formats
29
+ # Each entry is (magic_bytes, offset, detected_extension)
30
+ IMAGE_MAGIC_SIGNATURES = [
31
+ # JPEG: starts with FF D8 FF
32
+ (b"\xff\xd8\xff", 0, ".jpg"),
33
+ # PNG: starts with 89 50 4E 47 0D 0A 1A 0A
34
+ (b"\x89PNG\r\n\x1a\n", 0, ".png"),
35
+ # GIF87a and GIF89a
36
+ (b"GIF87a", 0, ".gif"),
37
+ (b"GIF89a", 0, ".gif"),
38
+ # WebP: starts with RIFF....WEBP
39
+ (b"RIFF", 0, ".webp"), # Additional check for WEBP at offset 8
40
+ ]
41
+
42
+
43
+ def detect_image_type(content: bytes) -> str | None:
44
+ """Detect image type from file content using magic bytes.
45
+
46
+ Args:
47
+ content: The file content bytes.
48
+
49
+ Returns:
50
+ The detected extension (e.g., ".jpg") or None if not a valid image.
51
+ """
52
+ if len(content) < 12:
53
+ return None
54
+
55
+ for magic, offset, ext in IMAGE_MAGIC_SIGNATURES:
56
+ if content[offset:offset + len(magic)] == magic:
57
+ # Special case for WebP: verify WEBP signature at offset 8
58
+ if ext == ".webp":
59
+ if content[8:12] != b"WEBP":
60
+ continue
61
+ return ext
62
+
63
+ return None
64
+
65
+
66
+ class FileSizeExceededError(Exception):
67
+ """Raised when uploaded file exceeds size limit."""
68
+
69
+ pass
70
+
71
+
72
+ class InvalidFileTypeError(Exception):
73
+ """Raised when uploaded file has invalid type."""
74
+
75
+ pass
76
+
77
+
78
+ class InvalidMagicBytesError(Exception):
79
+ """Raised when file content doesn't match a valid image format."""
80
+
81
+ pass
82
+
11
83
 
12
84
  class ImageStorageService:
13
85
  """Service for storing and managing wine label images."""
14
86
 
15
- def __init__(self, storage_path: Path | None = None) -> None:
87
+ def __init__(
88
+ self,
89
+ storage_path: Path | None = None,
90
+ max_size_bytes: int | None = None,
91
+ ) -> None:
16
92
  """Initialize the image storage service.
17
93
 
18
94
  Args:
19
95
  storage_path: Path to store images. Defaults to config setting.
96
+ max_size_bytes: Maximum file size in bytes. Defaults to config setting.
20
97
  """
21
98
  self.storage_path = storage_path or settings.image_storage_path
22
99
  self.storage_path.mkdir(parents=True, exist_ok=True)
100
+ self.max_size_bytes = max_size_bytes or settings.max_upload_size_bytes
101
+
102
+ def _validate_extension(self, filename: str | None) -> str:
103
+ """Validate and return the file extension.
104
+
105
+ Args:
106
+ filename: The original filename.
107
+
108
+ Returns:
109
+ Valid extension (e.g., ".jpg").
110
+
111
+ Raises:
112
+ InvalidFileTypeError: If extension is not allowed.
113
+ """
114
+ if not filename:
115
+ return ".jpg"
116
+
117
+ ext = Path(filename).suffix.lower()
118
+ if ext not in EXTENSION_MIME_MAP:
119
+ raise InvalidFileTypeError(
120
+ f"Invalid file type. Allowed types: {', '.join(EXTENSION_MIME_MAP.keys())}"
121
+ )
122
+ return ext
23
123
 
24
124
  async def save_image(self, upload_file: UploadFile) -> str:
25
- """Save an uploaded image file.
125
+ """Save an uploaded image file with size, type, and content validation.
26
126
 
27
127
  Args:
28
128
  upload_file: The uploaded file from FastAPI.
29
129
 
30
130
  Returns:
31
131
  The filename of the saved image.
32
- """
33
- # Generate unique filename
34
- ext = Path(upload_file.filename or "image.jpg").suffix.lower()
35
- if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
36
- ext = ".jpg"
37
132
 
133
+ Raises:
134
+ HTTPException: If file exceeds size limit, has invalid type, or
135
+ content doesn't match a valid image format.
136
+ """
137
+ # Validate extension first
138
+ try:
139
+ declared_ext = self._validate_extension(upload_file.filename)
140
+ except InvalidFileTypeError as e:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_400_BAD_REQUEST,
143
+ detail=str(e),
144
+ )
145
+
146
+ # Read content with size limit check
147
+ content = await upload_file.read()
148
+ if len(content) > self.max_size_bytes:
149
+ max_mb = self.max_size_bytes / (1024 * 1024)
150
+ raise HTTPException(
151
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
152
+ detail=f"File size exceeds maximum allowed size of {max_mb:.1f} MB",
153
+ )
154
+
155
+ # Validate magic bytes - ensure file content matches a valid image format
156
+ detected_ext = detect_image_type(content)
157
+ if detected_ext is None:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_400_BAD_REQUEST,
160
+ detail="Invalid file content. File does not appear to be a valid image.",
161
+ )
162
+
163
+ # Use the detected extension (more reliable than declared extension)
164
+ # This prevents attacks where malicious files are renamed to .jpg/.png
165
+ ext = detected_ext
166
+
167
+ # Generate unique filename with detected extension
38
168
  filename = f"{uuid.uuid4()}{ext}"
39
169
  file_path = self.storage_path / filename
40
170
 
41
171
  # Save file
42
- content = await upload_file.read()
43
172
  async with aiofiles.open(file_path, "wb") as f:
44
173
  await f.write(content)
45
174
 
@@ -37,30 +37,47 @@ class ClaudeVisionService:
37
37
 
38
38
  def __init__(self) -> None:
39
39
  """Initialize the Claude Vision service."""
40
- self._client = None
40
+ self._default_client = None
41
+
42
+ def _get_system_api_key(self) -> str | None:
43
+ """Get the system-wide API key from settings or environment."""
44
+ return settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
45
+
46
+ def _get_client(self, user_api_key: str | None = None):
47
+ """Get an Anthropic client, using user key if provided, else system key."""
48
+ try:
49
+ import anthropic
50
+
51
+ # Use user's API key if provided, otherwise use system key
52
+ api_key = user_api_key or self._get_system_api_key()
53
+ if not api_key:
54
+ raise ValueError("No Anthropic API key configured")
55
+
56
+ # If using system key, cache the client
57
+ if not user_api_key:
58
+ if self._default_client is None:
59
+ self._default_client = anthropic.Anthropic(api_key=api_key)
60
+ return self._default_client
61
+
62
+ # Create a new client for user-specific key
63
+ return anthropic.Anthropic(api_key=api_key)
64
+ except ImportError:
65
+ logger.error("anthropic package is not installed")
66
+ raise
41
67
 
42
68
  @property
43
69
  def client(self):
44
- """Lazy-load the Anthropic client."""
45
- if self._client is None:
46
- try:
47
- import anthropic
48
-
49
- # Check for API key in settings or environment
50
- api_key = settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
51
- if not api_key:
52
- raise ValueError("No Anthropic API key configured")
53
-
54
- self._client = anthropic.Anthropic(api_key=api_key)
55
- except ImportError:
56
- logger.error("anthropic package is not installed")
57
- raise
58
- return self._client
59
-
60
- def is_available(self) -> bool:
61
- """Check if Claude Vision is available."""
70
+ """Lazy-load the default Anthropic client (for backward compatibility)."""
71
+ return self._get_client()
72
+
73
+ def is_available(self, user_api_key: str | None = None) -> bool:
74
+ """Check if Claude Vision is available.
75
+
76
+ Args:
77
+ user_api_key: Optional user-specific API key to check.
78
+ """
62
79
  try:
63
- api_key = settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
80
+ api_key = user_api_key or self._get_system_api_key()
64
81
  return bool(api_key) and settings.use_claude_vision
65
82
  except Exception:
66
83
  return False
@@ -68,13 +85,15 @@ class ClaudeVisionService:
68
85
  async def analyze_label(
69
86
  self,
70
87
  image_data: bytes,
71
- media_type: str = "image/jpeg"
88
+ media_type: str = "image/jpeg",
89
+ user_api_key: str | None = None,
72
90
  ) -> dict[str, Any]:
73
91
  """Analyze a wine label image using Claude Vision.
74
92
 
75
93
  Args:
76
94
  image_data: Raw image data as bytes.
77
95
  media_type: MIME type of the image (image/jpeg, image/png, etc.)
96
+ user_api_key: Optional user-specific API key.
78
97
 
79
98
  Returns:
80
99
  Dictionary with parsed wine information.
@@ -83,8 +102,11 @@ class ClaudeVisionService:
83
102
  # Encode image to base64
84
103
  image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
85
104
 
105
+ # Get client (uses user key if provided, else system key)
106
+ client = self._get_client(user_api_key)
107
+
86
108
  # Call Claude API with vision
87
- message = self.client.messages.create(
109
+ message = client.messages.create(
88
110
  model="claude-sonnet-4-20250514",
89
111
  max_tokens=1024,
90
112
  messages=[
@@ -146,6 +168,7 @@ class ClaudeVisionService:
146
168
  back_image_data: bytes | None = None,
147
169
  front_media_type: str = "image/jpeg",
148
170
  back_media_type: str = "image/jpeg",
171
+ user_api_key: str | None = None,
149
172
  ) -> dict[str, Any]:
150
173
  """Analyze front and back wine label images.
151
174
 
@@ -154,11 +177,15 @@ class ClaudeVisionService:
154
177
  back_image_data: Optional back label image data.
155
178
  front_media_type: MIME type of front image.
156
179
  back_media_type: MIME type of back image.
180
+ user_api_key: Optional user-specific API key.
157
181
 
158
182
  Returns:
159
183
  Combined analysis results.
160
184
  """
161
185
  try:
186
+ # Get client (uses user key if provided, else system key)
187
+ client = self._get_client(user_api_key)
188
+
162
189
  # Build message content with images
163
190
  content = [
164
191
  {
@@ -199,7 +226,7 @@ class ClaudeVisionService:
199
226
  ])
200
227
 
201
228
  # Call Claude API
202
- message = self.client.messages.create(
229
+ message = client.messages.create(
203
230
  model="claude-sonnet-4-20250514",
204
231
  max_tokens=1024,
205
232
  messages=[{"role": "user", "content": content}],
@@ -555,6 +555,140 @@ h3 {
555
555
  max-width: 400px;
556
556
  }
557
557
 
558
+ .modal-content.modal-large {
559
+ max-width: 900px;
560
+ }
561
+
562
+ /* Checkin Confirmation Modal */
563
+ .checkin-confirm-header {
564
+ text-align: center;
565
+ margin-bottom: 1.5rem;
566
+ }
567
+
568
+ .checkin-confirm-header h3 {
569
+ color: var(--primary-color);
570
+ margin-bottom: 0.25rem;
571
+ }
572
+
573
+ .checkin-confirm-subtitle {
574
+ color: var(--text-muted);
575
+ font-size: 0.9rem;
576
+ }
577
+
578
+ .checkin-confirm-content {
579
+ display: grid;
580
+ grid-template-columns: 200px 1fr;
581
+ gap: 1.5rem;
582
+ margin-bottom: 1.5rem;
583
+ }
584
+
585
+ .checkin-confirm-image {
586
+ width: 200px;
587
+ height: 250px;
588
+ border-radius: var(--radius);
589
+ overflow: hidden;
590
+ border: 1px solid var(--border-color);
591
+ background: var(--background-color);
592
+ }
593
+
594
+ .checkin-confirm-image img {
595
+ width: 100%;
596
+ height: 100%;
597
+ object-fit: cover;
598
+ }
599
+
600
+ .checkin-confirm-form .form-grid {
601
+ display: grid;
602
+ grid-template-columns: repeat(2, 1fr);
603
+ gap: 1rem;
604
+ }
605
+
606
+ .checkin-confirm-form .form-group {
607
+ margin-bottom: 0;
608
+ }
609
+
610
+ .checkin-confirm-form .form-group.full-width {
611
+ grid-column: 1 / -1;
612
+ }
613
+
614
+ .checkin-confirm-form label {
615
+ display: block;
616
+ font-size: 0.85rem;
617
+ color: var(--text-muted);
618
+ margin-bottom: 0.25rem;
619
+ }
620
+
621
+ .checkin-confirm-form input,
622
+ .checkin-confirm-form textarea {
623
+ width: 100%;
624
+ padding: 0.5rem 0.75rem;
625
+ border: 1px solid var(--border-color);
626
+ border-radius: var(--radius);
627
+ font-size: 0.95rem;
628
+ }
629
+
630
+ .checkin-confirm-form textarea {
631
+ resize: vertical;
632
+ }
633
+
634
+ .checkin-confirm-ocr {
635
+ margin-bottom: 1.5rem;
636
+ border: 1px solid var(--border-color);
637
+ border-radius: var(--radius);
638
+ }
639
+
640
+ .checkin-confirm-ocr .collapsible-header {
641
+ padding: 0.75rem 1rem;
642
+ cursor: pointer;
643
+ display: flex;
644
+ justify-content: space-between;
645
+ align-items: center;
646
+ background: var(--background-color);
647
+ border-radius: var(--radius);
648
+ }
649
+
650
+ .checkin-confirm-ocr .collapsible-header:hover {
651
+ background: #f0ede8;
652
+ }
653
+
654
+ .checkin-confirm-ocr .collapsible-content {
655
+ padding: 1rem;
656
+ border-top: 1px solid var(--border-color);
657
+ }
658
+
659
+ .checkin-confirm-ocr .ocr-text-container {
660
+ max-height: 200px;
661
+ overflow-y: auto;
662
+ }
663
+
664
+ .checkin-confirm-ocr pre {
665
+ white-space: pre-wrap;
666
+ font-size: 0.85rem;
667
+ background: var(--background-color);
668
+ padding: 0.75rem;
669
+ border-radius: var(--radius);
670
+ margin-top: 0.5rem;
671
+ }
672
+
673
+ @media (max-width: 768px) {
674
+ .checkin-confirm-content {
675
+ grid-template-columns: 1fr;
676
+ }
677
+
678
+ .checkin-confirm-image {
679
+ width: 100%;
680
+ height: 200px;
681
+ }
682
+
683
+ .checkin-confirm-form .form-grid {
684
+ grid-template-columns: 1fr;
685
+ }
686
+
687
+ .modal-content.modal-large {
688
+ max-width: 100%;
689
+ }
690
+ }
691
+
558
692
  .modal-close {
559
693
  position: absolute;
560
694
  top: 1rem;
@@ -1044,6 +1178,19 @@ h3 {
1044
1178
  font-weight: 500;
1045
1179
  }
1046
1180
 
1181
+ .user-info .username-link {
1182
+ color: rgba(255, 255, 255, 0.9);
1183
+ text-decoration: none;
1184
+ padding: 0.4rem 0.75rem;
1185
+ border-radius: var(--radius);
1186
+ transition: all 0.2s ease;
1187
+ }
1188
+
1189
+ .user-info .username-link:hover {
1190
+ background: rgba(255, 255, 255, 0.15);
1191
+ color: white;
1192
+ }
1193
+
1047
1194
  .user-info .btn {
1048
1195
  padding: 0.4rem 0.75rem;
1049
1196
  font-size: 0.85rem;
@@ -1056,6 +1203,60 @@ h3 {
1056
1203
  background: rgba(255, 255, 255, 0.3);
1057
1204
  }
1058
1205
 
1206
+ /* Settings Page */
1207
+ .settings-section {
1208
+ background: var(--card-background);
1209
+ border-radius: var(--radius-lg);
1210
+ padding: 1.5rem;
1211
+ margin-bottom: 1.5rem;
1212
+ box-shadow: var(--shadow);
1213
+ border: 1px solid var(--border-color);
1214
+ }
1215
+
1216
+ .settings-section h3 {
1217
+ color: var(--primary-color);
1218
+ margin-bottom: 1rem;
1219
+ padding-bottom: 0.75rem;
1220
+ border-bottom: 1px solid var(--border-color);
1221
+ }
1222
+
1223
+ .settings-description {
1224
+ color: var(--text-muted);
1225
+ font-size: 0.9rem;
1226
+ margin-bottom: 1rem;
1227
+ line-height: 1.5;
1228
+ }
1229
+
1230
+ .api-key-status {
1231
+ display: flex;
1232
+ align-items: center;
1233
+ gap: 0.5rem;
1234
+ padding: 0.75rem 1rem;
1235
+ background: var(--background-color);
1236
+ border-radius: var(--radius);
1237
+ margin-bottom: 1rem;
1238
+ }
1239
+
1240
+ .api-key-status .status-indicator {
1241
+ width: 10px;
1242
+ height: 10px;
1243
+ border-radius: 50%;
1244
+ background: var(--text-muted);
1245
+ }
1246
+
1247
+ .api-key-status.configured .status-indicator {
1248
+ background: var(--success-color);
1249
+ }
1250
+
1251
+ .api-key-status.not-configured .status-indicator {
1252
+ background: var(--warning-color);
1253
+ }
1254
+
1255
+ .api-key-status .status-text {
1256
+ font-size: 0.9rem;
1257
+ color: var(--text-color);
1258
+ }
1259
+
1059
1260
  /* Hide main content when not logged in */
1060
1261
  body.logged-out main {
1061
1262
  display: none;